diff --git a/packages/etl/src/linear/helpers/content-parser.ts b/packages/etl/src/linear/helpers/content-parser.ts index 821797819e..436d24fe72 100644 --- a/packages/etl/src/linear/helpers/content-parser.ts +++ b/packages/etl/src/linear/helpers/content-parser.ts @@ -2,7 +2,7 @@ import { E_IMPORTER_KEYS } from "@/core"; import { ContentParser, ExternalImageParserExtension, - ExternalUserMentionParserExtension, + ExternalMentionParserExtension, ExternalFileParserExtension, } from "@/parser"; import { LinearContentParserConfig } from "../types"; @@ -26,8 +26,8 @@ export const getContentParser = (config: LinearContentParserConfig) => { apiBaseUrl: config.apiBaseUrl, downloadableUrlPrefix: "https://uploads.linear.app", }); - const userMentionParserExtension = new ExternalUserMentionParserExtension({ - userMap: config.userMap, + const userMentionParserExtension = new ExternalMentionParserExtension({ + entityMap: config.userMap, }); const issueMentionParserExtension = new LinearIssueMentionParserExtension({ diff --git a/packages/etl/src/parser/extensions/external/mentions-parser.ts b/packages/etl/src/parser/extensions/external/mentions-parser.ts index 9817b92d6a..f66610e548 100644 --- a/packages/etl/src/parser/extensions/external/mentions-parser.ts +++ b/packages/etl/src/parser/extensions/external/mentions-parser.ts @@ -1,34 +1,48 @@ import { HTMLElement } from "node-html-parser"; -import { v4 as uuidv4 } from 'uuid'; +import { v4 as uuidv4 } from "uuid"; import { IParserExtension } from "../../types"; -const MENTION_REGEX = /@([\w.-]+)/g - export type ExternalMentionParserConfig = { - userMap: Map; + // The symbol to use for mentions + mentionSymbol?: string; + + // Entity map to map mentions to entities + entityMap: Map; + // Add new options for fallback handling fallbackOptions?: { // If the user is not found in the userMap, we can replace the mention with a link - replaceWithLink?: (mention: string) => [label: string, url: string] + replaceWithLink?: (mention: string) => [label: string, url: string]; }; -} +}; -export class ExternalUserMentionParserExtension implements IParserExtension { - constructor(private readonly config: ExternalMentionParserConfig) { } +export class ExternalMentionParserExtension implements IParserExtension { + private readonly MENTION_REGEX: RegExp; + + constructor(private readonly config: ExternalMentionParserConfig) { + this.config.mentionSymbol = this.config.mentionSymbol ?? "@"; + this.MENTION_REGEX = new RegExp(`${this.config.mentionSymbol}([\\w.-]+)`, "g"); + } shouldParse(node: HTMLElement): boolean { - // Check if paragraph contains @ symbol anywhere in the text - return node.tagName === "P" && MENTION_REGEX.test(node.textContent) + // Only process if the node contains mentions with THIS extension's symbol + // Reset the regex before testing + this.MENTION_REGEX.lastIndex = 0; + const hasMatch = (node.textContent && this.MENTION_REGEX.test(node.textContent)) || false; + return hasMatch; } async mutate(node: HTMLElement): Promise { const text = node.textContent; + if (!text) { return Promise.resolve(node); } - // Find all mentions in the text - const mentions = text.match(MENTION_REGEX); + // Reset regex and find all mentions for THIS symbol only + this.MENTION_REGEX.lastIndex = 0; + const mentions = text.match(this.MENTION_REGEX); + if (!mentions || mentions.length === 0) { return node; } @@ -39,12 +53,12 @@ export class ExternalUserMentionParserExtension implements IParserExtension { } // If we have just a single mention that's the entire content, use original logic - const displayName = mentions[0].replace("@", ""); - const userId = this.config.userMap.get(displayName); + const displayName = mentions[0].replace(this.config.mentionSymbol ?? "@", ""); + const entityId = this.config.entityMap.get(displayName); - if (userId) { + if (entityId) { // User found in map - create a mention component - const mention = this.createMentionComponent(userId); + const mention = this.createMentionComponent(entityId); return mention; } else if (this.config.fallbackOptions) { // User not found - create an external link as fallback @@ -56,39 +70,43 @@ export class ExternalUserMentionParserExtension implements IParserExtension { } private handleMixedContent(node: HTMLElement, mentions: string[]): HTMLElement { - let html = node.textContent; + // Use innerHTML to preserve existing HTML elements (like links from previous extensions) + let html = node.innerHTML || node.textContent; + // Only process mentions that match THIS extension's symbol + const relevantMentions = mentions.filter((mention) => mention.startsWith(this.config.mentionSymbol ?? "@")); - // Process each mention and replace it in the HTML - for (const mention of mentions) { - const username = mention.replace("@", ""); - const userId = this.config.userMap.get(username); + // Process each relevant mention and replace it in the HTML + for (const mention of relevantMentions) { + const mentionText = mention.replace(this.config.mentionSymbol ?? "@", ""); + const entityId = this.config.entityMap.get(mentionText); - if (userId) { + if (entityId) { // Create mention component - const mentionComponent = this.createMentionComponent(userId); + const mentionComponent = this.createMentionComponent(entityId); const mentionHtml = mentionComponent.toString(); html = html.replace(mention, mentionHtml); } else if (this.config.fallbackOptions?.replaceWithLink) { // Create fallback link - const [label, url] = this.config.fallbackOptions.replaceWithLink(username); - const linkHtml = `${label}`; - html = html.replace(mention, linkHtml); + const [label, url] = this.config.fallbackOptions.replaceWithLink(mentionText); + const linkElement = new HTMLElement("a", {}, ""); + linkElement.setAttribute("href", encodeURIComponent(url)); + linkElement.setAttribute("target", "_blank"); + linkElement.textContent = label; + html = html.replace(mention, linkElement.toString()); } } - // Create a new paragraph with the processed HTML - const newParagraph = new HTMLElement("p", {}, ""); + // Create a new element with the same tag as the original + const newElement = new HTMLElement(node.tagName ? node.tagName.toLowerCase() : "p", {}, ""); - if (node.getAttribute("class")) { - newParagraph.setAttribute("class", node.getAttribute("class")!); + // Copy all attributes from the original node + for (const [key, value] of Object.entries(node.attributes)) { + newElement.setAttribute(key, value); } - // Set the innerHTML of the paragraph - // Note: This requires that your HTML parser supports setting innerHTML - // If not, you may need a different approach to construct the DOM - newParagraph.set_content(html); - - return newParagraph; + // Set the processed content + newElement.set_content(html); + return newElement; } private createMentionComponent(userId: string): HTMLElement { @@ -117,8 +135,9 @@ export class ExternalUserMentionParserExtension implements IParserExtension { return node; } - const [label, url] = this.config.fallbackOptions.replaceWithLink(node.textContent.replace("@", "")); - + const [label, url] = this.config.fallbackOptions.replaceWithLink( + node.textContent.replace(this.config.mentionSymbol ?? "@", "") + ); const linkElement = new HTMLElement("a", {}, ""); linkElement.setAttribute("href", url); linkElement.textContent = label; diff --git a/packages/etl/src/parser/extensions/plane/index.ts b/packages/etl/src/parser/extensions/plane/index.ts index 9d6284e63b..76111da855 100644 --- a/packages/etl/src/parser/extensions/plane/index.ts +++ b/packages/etl/src/parser/extensions/plane/index.ts @@ -1 +1,2 @@ export * from "./image-proxy-extension" +export * from "./plane-mentions-parser" diff --git a/packages/etl/src/parser/extensions/plane/plane-mentions-parser.ts b/packages/etl/src/parser/extensions/plane/plane-mentions-parser.ts new file mode 100644 index 0000000000..bdb535b06e --- /dev/null +++ b/packages/etl/src/parser/extensions/plane/plane-mentions-parser.ts @@ -0,0 +1,151 @@ +import { HTMLElement } from "node-html-parser"; +import { IParserExtension } from "../../types"; + +export type PlaneMentionResult = + | { + type: "text"; + text: string; + } + | { + type: "link"; + link: string; + label: string; + }; + +export type PlaneMentionCallback = (mentionData: { + id?: string; + entityIdentifier?: string; + entityName?: string; +}) => PlaneMentionResult; + +export type PlaneMentionParserConfig = { + callback: PlaneMentionCallback; +}; + +export class PlaneMentionParserExtension implements IParserExtension { + constructor(private readonly config: PlaneMentionParserConfig) { } + + shouldParse(node: HTMLElement): boolean { + // Check if the node contains mention-component elements + return this.findMentionComponents(node).length > 0; + } + + async mutate(node: HTMLElement): Promise { + // Find all mention components in the node + const mentionComponents = this.findMentionComponents(node); + + if (mentionComponents.length === 0) { + return node; + } + + // If the node itself is a mention component, process it directly + if (node.tagName?.toLowerCase() === 'mention-component') { + return this.processMentionComponent(node); + } + + // If the node contains mention components, process each one + return this.processMixedContent(node, mentionComponents); + } + + private findMentionComponents(node: HTMLElement): HTMLElement[] { + const components: HTMLElement[] = []; + + // Check if the current node is a mention component + if (node.tagName?.toLowerCase() === 'mention-component') { + components.push(node); + } + + // Recursively check children + if (node.childNodes) { + for (const child of node.childNodes) { + if (child instanceof HTMLElement) { + components.push(...this.findMentionComponents(child)); + } + } + } + + return components; + } + + private processMentionComponent(mentionComponent: HTMLElement): HTMLElement { + // Extract data from the mention component + const mentionData = this.extractMentionData(mentionComponent); + + // Call the callback to get the replacement + const result = this.config.callback(mentionData); + + // Create the replacement element based on the result + return this.createReplacementElement(result); + } + + private processMixedContent(node: HTMLElement, mentionComponents: HTMLElement[]): HTMLElement { + // Clone the node to avoid modifying the original + const newElement = new HTMLElement(node.tagName ? node.tagName.toLowerCase() : "p", {}, ""); + + // Copy all attributes from the original node + for (const [key, value] of Object.entries(node.attributes)) { + newElement.setAttribute(key, value); + } + + // Get the HTML content + let html = node.innerHTML || node.textContent || ''; + + // Process each mention component + for (const mentionComponent of mentionComponents) { + const mentionData = this.extractMentionData(mentionComponent); + const result = this.config.callback(mentionData); + const replacement = this.createReplacementElement(result); + + // Replace the mention component HTML with the replacement HTML + const mentionHtml = mentionComponent.toString(); + const replacementHtml = replacement.toString(); + html = html.replace(mentionHtml, replacementHtml); + } + + // Set the processed content + newElement.set_content(html); + return newElement; + } + + private extractMentionData(mentionComponent: HTMLElement): any { + const data: any = {}; + + // Extract common attributes + const id = mentionComponent.getAttribute('id'); + const entityIdentifier = mentionComponent.getAttribute('entity_identifier'); + const entityName = mentionComponent.getAttribute('entity_name'); + + if (id) data.id = id; + if (entityIdentifier) data.entityIdentifier = entityIdentifier; + if (entityName) data.entityName = entityName; + // Extract any additional attributes + for (const [key, value] of Object.entries(mentionComponent.attributes)) { + if (!['id', 'entity_identifier', 'entity_name'].includes(key)) { + data[key] = value; + } + } + + return data; + } + + private createReplacementElement(result: PlaneMentionResult): HTMLElement { + if (result.type === "text") { + // Create a text node (we'll use a span to wrap it) + const textElement = new HTMLElement("span", {}, ""); + textElement.textContent = result.text; + return textElement; + } else if (result.type === "link") { + // Create a link element + const linkElement = new HTMLElement("a", {}, ""); + linkElement.setAttribute("href", result.link); + linkElement.setAttribute("target", "_blank"); + linkElement.textContent = result.label; + return linkElement; + } + + // Fallback - should never happen with proper typing + const fallbackElement = new HTMLElement("span", {}, ""); + fallbackElement.textContent = "[mention]"; + return fallbackElement; + } +} diff --git a/silo/package.json b/silo/package.json index 6f73e8973f..3ef6ae0e25 100644 --- a/silo/package.json +++ b/silo/package.json @@ -29,6 +29,7 @@ "@types/cookie-parser": "^1.4.8", "@types/jest": "^29.5.14", "@types/pg": "^8.11.14", + "@types/turndown": "^5.0.5", "amqplib": "^0.10.4", "axios": "^1.8.3", "bluebird": "^3.7.2", @@ -46,6 +47,7 @@ "reflect-metadata": "^0.2.2", "source-map-support": "^0.5.21", "ts-jest": "^29.2.6", + "turndown": "^7.2.0", "type-fest": "^4.26.1", "uuid": "^11.0.3", "winston": "^3.14.2", diff --git a/silo/src/apps/slack/controllers/index.ts b/silo/src/apps/slack/controllers/index.ts index 5dbb61005a..968055da08 100644 --- a/silo/src/apps/slack/controllers/index.ts +++ b/silo/src/apps/slack/controllers/index.ts @@ -553,8 +553,8 @@ export default class SlackController { } const { workspaceConnection, planeClient } = details; - const values = parseIssueFormData(payload.view.state.values); - const labels = await planeClient.label.list(workspaceConnection.workspace_slug, values.project); + const values = await parseIssueFormData(payload.view.state.values); + const labels = await planeClient.label.list(workspaceConnection.workspace_id, values.project); const filteredLabels = labels.results .filter((label) => label.name.toLowerCase().includes(text.toLowerCase())) .sort((a, b) => a.name.localeCompare(b.name)); diff --git a/silo/src/apps/slack/helpers/connection-details.ts b/silo/src/apps/slack/helpers/connection-details.ts index ed2c636bea..c6d4e920db 100644 --- a/silo/src/apps/slack/helpers/connection-details.ts +++ b/silo/src/apps/slack/helpers/connection-details.ts @@ -1,13 +1,13 @@ import { E_INTEGRATION_KEYS } from "@plane/etl/core"; import { createSlackService } from "@plane/etl/slack"; import { Client as PlaneClient, PlaneWebhookPayload } from "@plane/sdk"; +import { TWorkspaceCredential, TWorkspaceConnection } from "@plane/types"; import { env } from "@/env"; import { logger } from "@/logger"; import { getAPIClient } from "@/services/client"; import { slackAuth } from "../auth/auth"; -import { getRefreshCredentialHandler } from "./update-credentials"; -import { TWorkspaceCredential, TWorkspaceConnection } from "@plane/types"; import { TSlackConnectionDetails, TSlackWorkspaceConnectionConfig } from "../types/types"; +import { getRefreshCredentialHandler } from "./update-credentials"; const apiClient = getAPIClient(); @@ -73,6 +73,7 @@ export const getConnectionDetails = async ( return { workspaceConnection, credentials, + botCredentials: adminCredentials, slackService, planeClient, missingUserCredentials: !userCredentials, @@ -265,6 +266,7 @@ export const getConnectionDetailsForIssue = async (payload: PlaneWebhookPayload, ); return { + workspaceConnection, entityConnection, slackService, isUser, diff --git a/silo/src/apps/slack/helpers/content-parser/index.ts b/silo/src/apps/slack/helpers/content-parser/index.ts new file mode 100644 index 0000000000..0beabda320 --- /dev/null +++ b/silo/src/apps/slack/helpers/content-parser/index.ts @@ -0,0 +1,74 @@ +import { ContentParser, ExternalMentionParserExtension, IParserExtension, PlaneMentionParserExtension } from "@plane/etl/parser"; + +export type TSlackContentParserConfig = { + userMap: Map + teamDomain: string +} + +export const getSlackContentParser = (config: TSlackContentParserConfig) => { + const extensions: IParserExtension[] = [ + // For channels - process first since they're less likely to have entity mappings + new ExternalMentionParserExtension({ + mentionSymbol: "#", + entityMap: new Map(), // Empty map for channels + fallbackOptions: { + replaceWithLink(mention) { + return [`Slack Channel: ${mention}`, `https://${config.teamDomain}.slack.com/archives/${mention}`] + } + } + }), + // For users - process second since they might have entity mappings + new ExternalMentionParserExtension({ + mentionSymbol: "@", + entityMap: config.userMap, + fallbackOptions: { + replaceWithLink(mention) { + return [`Slack User: ${mention}`, `https://${config.teamDomain}.slack.com/team/${mention}`] + } + } + }), + + new ExternalMentionParserExtension({ + mentionSymbol: "!", + entityMap: new Map(), + fallbackOptions: { + replaceWithLink(mention) { + // TODO: Check this + return [`Slack Broadcast: ${mention}`, `https://${config.teamDomain}.slack.com/team/${mention}`] + } + } + }), + ] + + return new ContentParser(extensions, [], []) +} + +export type TPlaneContentParserConfig = { + workspaceSlug: string + appBaseUrl: string + userMap: Map // Map of slack user id to plane user id +} + +export const getPlaneContentParser = (config: TPlaneContentParserConfig) => { + const extensions: IParserExtension[] = [ + new PlaneMentionParserExtension({ + callback: (mentionData) => { + const user = config.userMap.get(mentionData.entityIdentifier ?? "") + if (!user) { + return { + type: "link", + label: "Plane User", + link: `${config.appBaseUrl}/${config.workspaceSlug}/profile/${mentionData.entityIdentifier}`, + } + } else { + return { + type: "text", + text: `<@${user}>`, + } + } + } + }) + ] + + return new ContentParser(extensions, [], []) +} diff --git a/silo/src/apps/slack/helpers/content-parser/parser.test.ts b/silo/src/apps/slack/helpers/content-parser/parser.test.ts new file mode 100644 index 0000000000..6de0da857e --- /dev/null +++ b/silo/src/apps/slack/helpers/content-parser/parser.test.ts @@ -0,0 +1,175 @@ +import { getSlackContentParser, TSlackContentParserConfig } from "."; + +describe("Slack Content Parser", () => { + const mockConfig: TSlackContentParserConfig = { + userMap: new Map([ + ["U123456789", "plane-user-id-1"], + ["U987654321", "plane-user-id-2"], + ]), + teamDomain: "myteam", + }; + + it("should parse Slack rich text with user mentions, channel mentions, and broadcasts", async () => { + const slackRichText = ` +Hello @U123456789 and @U987654321! + +Please check the #C1234567890 channel for updates. + +!channel announcement for everyone. + +Visit our website: https://example.com + +Here's a formatted link: [Click here](https://plane.so) + +Some **bold text** and *italic text* and \`inline code\`. + +- First item +- Second item +- Third item + `.trim(); + + const parser = getSlackContentParser(mockConfig); + const result = await parser.toPlaneHtml(slackRichText); + + // Test mention components structure (with UUID pattern matching) + expect(result).toMatch( + /<\/mention-component>/ + ); + expect(result).toMatch( + /<\/mention-component>/ + ); + + // Test channel mentions + expect(result).toContain('href="https%3A%2F%2Fmyteam.slack.com%2Farchives%2FC1234567890"'); + expect(result).toContain('Slack Channel: C1234567890'); + + // Test broadcast mentions + expect(result).toContain('href="https%3A%2F%2Fmyteam.slack.com%2Fteam%2Fchannel"'); + expect(result).toContain('Slack Broadcast: channel'); + + // Test regular links + expect(result).toContain('href="https://example.com"'); + expect(result).toContain('href="https://plane.so"'); + + // Test text formatting + expect(result).toContain('bold text'); + expect(result).toContain('italic text'); + expect(result).toContain('inline code'); + + // Test list structure + expect(result).toContain('
    '); + expect(result).toContain('
  • First item
  • '); + expect(result).toContain('
  • Second item
  • '); + expect(result).toContain('
  • Third item
  • '); + expect(result).toContain('
'); + }); + + it("should handle user mentions with fallback for unmapped users", async () => { + const slackRichText = "Hey @U111111111, can you help @U123456789?"; + + const parser = getSlackContentParser(mockConfig); + const result = await parser.toPlaneHtml(slackRichText); + + // Should map known user and create link for unknown user + expect(result).toMatch( + /<\/mention-component>/ + ); + expect(result).toContain('https%3A%2F%2Fmyteam.slack.com%2Fteam%2FU111111111'); + }); + + it("should handle channel mentions with fallback links", async () => { + const slackRichText = "Check #C1234567890 and #C0987654321 channels"; + + const parser = getSlackContentParser(mockConfig); + const result = await parser.toPlaneHtml(slackRichText); + + // Should create links for channels since entityMap is empty + expect(result).toContain('https%3A%2F%2Fmyteam.slack.com%2Farchives%2FC1234567890'); + expect(result).toContain('https%3A%2F%2Fmyteam.slack.com%2Farchives%2FC0987654321'); + }); + + it("should handle broadcast mentions", async () => { + const slackRichText = "Attention !here and !channel"; + + const parser = getSlackContentParser(mockConfig); + const result = await parser.toPlaneHtml(slackRichText); + + // Should create links for broadcasts + expect(result).toContain('https%3A%2F%2Fmyteam.slack.com%2Fteam%2Fhere'); + expect(result).toContain('https%3A%2F%2Fmyteam.slack.com%2Fteam%2Fchannel'); + }); + + it("should handle mixed content with multiple mention types", async () => { + const slackRichText = ` + Team update from @U123456789: + + 1. Check #general for announcements + 2. !everyone please review the docs + 3. Contact @U987654321 for questions + + Link: https://docs.example.com + `.trim(); + + const parser = getSlackContentParser(mockConfig); + const result = await parser.toPlaneHtml(slackRichText); + + // Verify all mention types are processed + expect(result).toContain('entity_identifier="plane-user-id-1"'); // Mapped user + expect(result).toContain('entity_identifier="plane-user-id-2"'); // Mapped user + expect(result).toContain("Slack Channel: general"); // Channel mention + expect(result).toContain("Slack Broadcast: everyone"); // Broadcast mention + expect(result).toContain("https://docs.example.com"); // Regular link + }); + + it("should preserve markdown formatting in non-mention text", async () => { + const slackRichText = ` + **Bold text** and *italic text* + + \`Code snippet\` + + > Quote block + + - List item 1 + - List item 2 + `.trim(); + + const parser = getSlackContentParser(mockConfig); + const result = await parser.toPlaneHtml(slackRichText); + + // Should preserve markdown formatting + expect(result).toContain('Bold text'); + expect(result).toContain('italic text'); + expect(result).toContain('Code snippet'); + expect(result).toContain('
\n

Quote block

\n
'); + expect(result).toContain('
  • List item 1
  • '); + expect(result).toContain('
  • List item 2
  • '); + }); + + it("should handle complex Slack message with rich text elements", async () => { + const slackRichText = ` + Hi @U123456789! 👋 + + Please review the PR in #dev-team channel. + + !here - This is important for everyone. + + Resources: + - Documentation: https://docs.plane.so + - Support: [Contact us](https://plane.so/contact) + + Thanks! + `.trim(); + + const parser = getSlackContentParser(mockConfig); + const result = await parser.toPlaneHtml(slackRichText); + + // Verify the structure is maintained and all elements are processed + // + expect(result).toMatch( + /<\/mention-component>/ + ); + expect(result).toContain("Slack Channel: dev-team"); + expect(result).toContain("Slack Broadcast: here"); + expect(result).toContain('https://docs.plane.so'); + }); +}); diff --git a/silo/src/apps/slack/helpers/parse-issue-form.ts b/silo/src/apps/slack/helpers/parse-issue-form.ts index e88669101c..899d7e4601 100644 --- a/silo/src/apps/slack/helpers/parse-issue-form.ts +++ b/silo/src/apps/slack/helpers/parse-issue-form.ts @@ -1,13 +1,14 @@ +import { RichTextElement, RichTextSection, RichTextList, RichTextBlockElement, RichTextBlock } from "@slack/types"; import { ParsedIssueData, ParsedLinkWorkItemData } from "../types/types"; -export const parseIssueFormData = (values: any): ParsedIssueData => { +export const parseIssueFormData = async (values: any): Promise => { const parsed: ParsedIssueData = { title: "", project: "", }; // Loop through all blocks - Object.entries(values).forEach(([_, blockData]: [string, any]) => { + Object.entries(values).forEach(async ([_, blockData]: [string, any]) => { // Check for project if (blockData.project?.type === "static_select") { parsed.project = blockData.project.selected_option?.value; @@ -19,8 +20,8 @@ export const parseIssueFormData = (values: any): ParsedIssueData => { } // Check for description - if (blockData.issue_description?.type === "plain_text_input") { - parsed.description = blockData.issue_description.value; + if (blockData.issue_description?.type === "rich_text_input") { + parsed.description = richTextBlockToMrkdwn(blockData.issue_description.rich_text_value); } // Check for state @@ -48,6 +49,85 @@ export const parseIssueFormData = (values: any): ParsedIssueData => { return parsed; }; +export const quoteMrkdwn = (text: string): string => ('> ' + text).split('\n').join('\n> ') + +const applyMrkdwnStyle = (text: string, style: RichTextElement['style']): string => { + if (!style || text.startsWith(' ') || text.endsWith(' ')) return text + + if (style.code) text = `\`${text}\`` + if (style.strike) text = `~${text}~` + if (style.italic) text = `_${text}_` + if (style.bold) text = `*${text}*` + + return text +} + +// Conversion from these docs: https://api.slack.com/reference/surfaces/formatting#advanced +const richTextElementToMrkdwn = (element: RichTextElement): string => { + switch (element.type) { + case 'broadcast': + return applyMrkdwnStyle(`!${element.range}`, element.style) + case 'channel': + return applyMrkdwnStyle(`#${element.channel_id}`, element.style) + case 'color': + return applyMrkdwnStyle(element.value, element.style) + case 'date': + if (element.url) { + return applyMrkdwnStyle(``, element.style) + } + return applyMrkdwnStyle(``, element.style) + case 'emoji': + // emoji has unicode property, let's use that to get the emoji + return String.fromCodePoint(parseInt(element.unicode!, 16)) + case 'link': + if (element.text) { + return applyMrkdwnStyle(`[${element.text}](${element.url})`, element.style) + } + return applyMrkdwnStyle(element.url, element.style) + case 'team': // There is no documented way to display this nicely in mrkdwn + return applyMrkdwnStyle(element.team_id, element.style) + case 'text': + return applyMrkdwnStyle(element.text, element.style) + case 'user': + return applyMrkdwnStyle(`@${element.user_id}`, element.style) + case 'usergroup': + return applyMrkdwnStyle(element.usergroup_id, element.style) + default: + return '' + } +} + +const richTextSectionToMrkdwn = (section: RichTextSection): string => section.elements.map(richTextElementToMrkdwn).join('') + +const richTextListToMrkdwn = (element: RichTextList): string => { + let mrkdwn = '' + for (const section of element.elements) { + mrkdwn += `${' '.repeat(element.indent ?? 0)} • ${richTextSectionToMrkdwn(section)}\n` + } + + return mrkdwn +} + +const richTextBlockElementToMrkdwn = (element: RichTextBlockElement): string => { + switch (element.type) { + case 'rich_text_list': + return richTextListToMrkdwn(element) + case 'rich_text_preformatted': + return '```\n' + element.elements.map(richTextElementToMrkdwn).join('') + '\n```' + case 'rich_text_quote': + return quoteMrkdwn(element.elements.map(richTextElementToMrkdwn).join('')) + case 'rich_text_section': + return element.elements.map(richTextElementToMrkdwn).join('') + default: + return '' + } +} + +export const richTextBlockToMrkdwn = (richTextBlock: RichTextBlock) => { + const mrkdwn = richTextBlock.elements.map(richTextBlockElementToMrkdwn).join('') + + return mrkdwn +} export const parseLinkWorkItemFormData = (values: any): ParsedLinkWorkItemData | undefined => { let parsed: ParsedLinkWorkItemData | undefined = undefined @@ -73,3 +153,84 @@ export const parseLinkWorkItemFormData = (values: any): ParsedLinkWorkItemData | return parsed; }; + +/** + * Extracts rich text elements from Slack message blocks or parses text with links + */ +export const extractRichTextElements = (title?: string, blocks?: any[]): RichTextBlockElement[] => { + // First, try to extract from blocks if available + if (blocks && blocks.length > 0) { + for (const block of blocks) { + if (block.type === "rich_text" && block.elements && block.elements.length > 0) { + // Return the entire rich text block elements structure + // This preserves all formatting: lists, quotes, links, styling, etc. + return block.elements; + } + } + } + + // Fallback to parsing the raw text + return parseSlackLinksToRichText(title ?? "") as RichTextBlockElement[]; +}; + +/** + * Parses Slack message text containing links and converts them to rich text elements + */ +export const parseSlackLinksToRichText = (text: string) => { + if (!text) return []; + + const elements: any[] = []; + // Match both and patterns + const linkRegex = /<([^|>]+)(?:\|([^>]+))?>/g; + let lastIndex = 0; + let match; + + while ((match = linkRegex.exec(text)) !== null) { + const [fullMatch, url, linkText] = match; + const matchStart = match.index; + + // Add any text before the link as a regular text element + if (matchStart > lastIndex) { + const beforeText = text.slice(lastIndex, matchStart); + if (beforeText.trim()) { + elements.push({ + type: "text", + text: beforeText, + }); + } + } + + // Add the link element + elements.push({ + type: "link", + text: linkText || url, // Use linkText if provided, otherwise use URL as text + url: url, + }); + + lastIndex = matchStart + fullMatch.length; + } + + // Add any remaining text after the last link + if (lastIndex < text.length) { + const remainingText = text.slice(lastIndex); + if (remainingText.trim()) { + elements.push({ + type: "text", + text: remainingText, + }); + } + } + + // If no links were found, return the original text as a single text element + if (elements.length === 0 && text.trim()) { + elements.push({ + type: "text", + text: text, + }); + } + + return [{ + type: "rich_text_section", + elements: elements, + }]; +} diff --git a/silo/src/apps/slack/types/types.ts b/silo/src/apps/slack/types/types.ts index 4afa932b1d..74a38432bd 100644 --- a/silo/src/apps/slack/types/types.ts +++ b/silo/src/apps/slack/types/types.ts @@ -1,7 +1,7 @@ import { ISlackChannel, ISlackUser, SlackService, TSlackPayload } from "@plane/etl/slack"; -import { ENTITIES } from "../helpers/constants"; import { PlaneActivity, Client as PlaneClient } from "@plane/sdk"; import { TWorkspaceConnection, TWorkspaceCredential } from "@plane/types"; +import { ENTITIES } from "../helpers/constants"; export interface ParsedIssueData { project: string; @@ -38,6 +38,7 @@ export type ShortcutActionPayload = { text?: string; thread_ts?: string; ts?: string; + blocks?: any[]; }; channel: { id: string; @@ -66,6 +67,7 @@ export type TSlackWorkspaceConnectionConfig = { export type TSlackConnectionDetails = { workspaceConnection: TWorkspaceConnection; credentials: TWorkspaceCredential; + botCredentials: TWorkspaceCredential; slackService: SlackService; planeClient: PlaneClient; missingUserCredentials?: boolean; @@ -107,13 +109,13 @@ export type ActivityForSlack = { field: string; actor: string; } & ( - | { + | { isArrayField: true; removed: string[]; added: string[]; } - | { + | { isArrayField: false; newValue: string; } -); + ); diff --git a/silo/src/apps/slack/views/create-weblink-modal.ts b/silo/src/apps/slack/views/create-weblink-modal.ts index 5a259fbd3c..36b63845ee 100644 --- a/silo/src/apps/slack/views/create-weblink-modal.ts +++ b/silo/src/apps/slack/views/create-weblink-modal.ts @@ -1,57 +1,55 @@ import { ENTITIES } from "../helpers/constants"; import { E_MESSAGE_ACTION_TYPES, MetadataPayloadShort } from "../types/types"; -export const createWebLinkModal = (payload: MetadataPayloadShort) => { - return { - type: "modal", - callback_id: E_MESSAGE_ACTION_TYPES.ISSUE_WEBLINK_SUBMISSION, - private_metadata: JSON.stringify({ entityType: ENTITIES.ISSUE_WEBLINK_SUBMISSION, entityPayload: payload }), - title: { - type: "plain_text", - text: "Create Web Link", - emoji: true, - }, - submit: { - type: "plain_text", - text: "Submit", - emoji: true, - }, - close: { - type: "plain_text", - text: "Cancel", - emoji: true, - }, - blocks: [ - { - type: "input", - element: { - type: "plain_text_input", - placeholder: { - type: "plain_text", - text: "Enter link label", - }, - }, - label: { +export const createWebLinkModal = (payload: MetadataPayloadShort) => ({ + type: "modal", + callback_id: E_MESSAGE_ACTION_TYPES.ISSUE_WEBLINK_SUBMISSION, + private_metadata: JSON.stringify({ entityType: ENTITIES.ISSUE_WEBLINK_SUBMISSION, entityPayload: payload }), + title: { + type: "plain_text", + text: "Create Web Link", + emoji: true, + }, + submit: { + type: "plain_text", + text: "Submit", + emoji: true, + }, + close: { + type: "plain_text", + text: "Cancel", + emoji: true, + }, + blocks: [ + { + type: "input", + element: { + type: "plain_text_input", + placeholder: { type: "plain_text", - text: "Display Title", - emoji: true, + text: "Enter link label", }, }, - { - type: "input", - element: { - type: "url_text_input", - placeholder: { - type: "plain_text", - text: "Enter URL", - }, - }, - label: { + label: { + type: "plain_text", + text: "Display Title", + emoji: true, + }, + }, + { + type: "input", + element: { + type: "url_text_input", + placeholder: { type: "plain_text", - text: "Web URL 🔗", - emoji: true, + text: "Enter URL", }, }, - ], - }; -}; + label: { + type: "plain_text", + text: "Web URL 🔗", + emoji: true, + }, + }, + ], +}); diff --git a/silo/src/apps/slack/views/custom-blocks.ts b/silo/src/apps/slack/views/custom-blocks.ts index 56257708f9..8e57d3b009 100644 --- a/silo/src/apps/slack/views/custom-blocks.ts +++ b/silo/src/apps/slack/views/custom-blocks.ts @@ -1,4 +1,4 @@ -import { InputBlock, StaticSelect, PlainTextInput, MultiExternalSelect, Checkboxes, ExternalSelect } from "@slack/types"; +import { InputBlock, StaticSelect, PlainTextInput, MultiExternalSelect, Checkboxes, ExternalSelect, RichTextInput } from "@slack/types"; export interface StaticSelectInputBlock extends InputBlock { element: StaticSelect; @@ -19,3 +19,7 @@ export interface CheckboxInputBlock extends InputBlock { export interface ExternalSelectInputBlock extends InputBlock { element: ExternalSelect; } + +export interface RichTextInputBlock extends InputBlock { + element: RichTextInput; +} diff --git a/silo/src/apps/slack/views/issue-modal-full.ts b/silo/src/apps/slack/views/issue-modal-full.ts index 6a9c21ccff..eb7a809ec3 100644 --- a/silo/src/apps/slack/views/issue-modal-full.ts +++ b/silo/src/apps/slack/views/issue-modal-full.ts @@ -1,13 +1,15 @@ -import { ACTIONS } from "../helpers/constants"; -import { PlainTextOption } from "../helpers/slack-options"; import { PlainTextElement } from "@slack/types"; +import { ACTIONS } from "../helpers/constants"; +import { extractRichTextElements } from "../helpers/parse-issue-form"; +import { PlainTextOption } from "../helpers/slack-options"; +import { E_MESSAGE_ACTION_TYPES } from "../types/types"; import { CheckboxInputBlock, MultiExternalSelectInputBlock, PlainTextInputBlock, + RichTextInputBlock, StaticSelectInputBlock, } from "./custom-blocks"; -import { E_MESSAGE_ACTION_TYPES } from "../types/types"; export type IssueModalViewFull = { type: "modal"; @@ -22,7 +24,7 @@ export type IssueModalViewFull = { export type IssueModalViewBlocks = [ StaticSelectInputBlock, PlainTextInputBlock, - PlainTextInputBlock, + RichTextInputBlock, StaticSelectInputBlock, StaticSelectInputBlock, MultiExternalSelectInputBlock, @@ -43,7 +45,8 @@ export const createIssueModalViewFull = ( }, title?: string, privateMetadata: string = "{}", - showThreadSync: boolean = true + showThreadSync: boolean = true, + messageBlocks?: any[] // Add optional messageBlocks parameter ): IssueModalViewFull => ({ type: "modal", callback_id: E_MESSAGE_ACTION_TYPES.CREATE_NEW_WORK_ITEM, @@ -63,7 +66,6 @@ export const createIssueModalViewFull = ( text: "Discard Issue", emoji: true, }, - // @ts-ignore blocks: [ { dispatch_action: true, @@ -105,10 +107,12 @@ export const createIssueModalViewFull = ( type: "input", optional: true, element: { - type: "plain_text_input", + type: "rich_text_input", action_id: ACTIONS.ISSUE_DESCRIPTION, - multiline: true, - initial_value: title ?? "", + initial_value: { + type: "rich_text", + elements: extractRichTextElements(title, messageBlocks), + }, placeholder: { type: "plain_text", text: "Issue Description (Optional)", @@ -181,24 +185,24 @@ export const createIssueModalViewFull = ( ...(showThreadSync ? [ { - type: "input", + type: "input" as const, optional: true, element: { - type: "checkboxes", + type: "checkboxes" as const, options: [ { text: { type: "plain_text", text: "Sync slack comments with plane comments and vice versa", emoji: true, - }, + } as PlainTextElement, value: "true", }, ], action_id: "enable_thread_sync", }, label: { - type: "plain_text", + type: "plain_text" as const, text: "Thread Sync", emoji: true, }, diff --git a/silo/src/apps/slack/worker/handlers/handle-message.ts b/silo/src/apps/slack/worker/handlers/handle-message.ts index 89aef03422..e3d697c97d 100644 --- a/silo/src/apps/slack/worker/handlers/handle-message.ts +++ b/silo/src/apps/slack/worker/handlers/handle-message.ts @@ -3,7 +3,10 @@ import { CONSTANTS } from "@/helpers/constants"; import { logger } from "@/logger"; import { getAPIClient } from "@/services/client"; import { getConnectionDetails } from "../../helpers/connection-details"; +import { getSlackContentParser } from "../../helpers/content-parser"; +import { extractRichTextElements, richTextBlockToMrkdwn } from "../../helpers/parse-issue-form"; import { extractPlaneResource } from "../../helpers/parse-plane-resources"; +import { TSlackWorkspaceConnectionConfig } from "../../types/types"; import { createCycleLinkback } from "../../views/cycle-linkback"; import { createSlackLinkback } from "../../views/issue-linkback"; import { createModuleLinkback } from "../../views/module-linkback"; @@ -68,18 +71,37 @@ export const handleMessageEvent = async (data: SlackEventPayload) => { const eConnection = entityConnection[0]; + const config = workspaceConnection.config as TSlackWorkspaceConnectionConfig; + const members = await planeClient.users.list(workspaceConnection.workspace_slug, eConnection.project_id ?? ""); const userInfo = await slackService.getUserInfo(data.event.user); const issueId = eConnection.entity_slug ?? eConnection.issue_id; const planeUser = members.find((member) => member.email === userInfo?.user.profile.email); + const userMap = new Map(); + config.userMap?.forEach((user) => { + userMap.set(user.slackUser, user.planeUserId); + }); + + const parser = getSlackContentParser({ + userMap, + teamDomain: workspaceConnection.connection_slug ?? "", + }); + + const richTextElements = extractRichTextElements(data.event.text, data.event.blocks); + const richText = richTextBlockToMrkdwn({ + type: "rich_text", + elements: richTextElements + }) + const parsedDescription = await parser.toPlaneHtml(richText); + await planeClient.issueComment.create( workspaceConnection.workspace_slug, eConnection.project_id ?? "", issueId ?? "", { - comment_html: `

    ${data.event.text}

    `, + comment_html: parsedDescription, external_source: "SLACK_COMMENT", external_id: data.event.event_ts, created_by: planeUser?.id, @@ -108,7 +130,6 @@ export const handleLinkSharedEvent = async (data: SlackEventPayload) => { if (resource === null) return; if (resource.type === "issue") { - if (resource.workspaceSlug !== workspaceConnection.workspace_slug) return; const issue = await planeClient.issue.getIssueByIdentifierWithFields( @@ -119,13 +140,7 @@ export const handleLinkSharedEvent = async (data: SlackEventPayload) => { ); const states = await planeClient.state.list(workspaceConnection.workspace_slug, issue.project.id ?? ""); - const linkBack = createSlackLinkback( - workspaceConnection.workspace_slug, - issue, - states.results, - false, - true - ); + const linkBack = createSlackLinkback(workspaceConnection.workspace_slug, issue, states.results, false, true); unfurlMap[link.url] = linkBack; } else { diff --git a/silo/src/apps/slack/worker/handlers/message-action.ts b/silo/src/apps/slack/worker/handlers/message-action.ts index 9ba3d97df8..35d9af407b 100644 --- a/silo/src/apps/slack/worker/handlers/message-action.ts +++ b/silo/src/apps/slack/worker/handlers/message-action.ts @@ -1,14 +1,14 @@ +import { E_INTEGRATION_KEYS } from "@plane/etl/core"; import { TMessageActionPayload } from "@plane/etl/slack"; import { convertToSlackOptions } from "@/apps/slack/helpers/slack-options"; import { createProjectSelectionModal } from "@/apps/slack/views"; import { logger } from "@/logger"; +import { getAPIClient } from "@/services/client"; import { getConnectionDetails } from "../../helpers/connection-details"; +import { ENTITIES } from "../../helpers/constants"; +import { E_MESSAGE_ACTION_TYPES } from "../../types/types"; import { getAccountConnectionBlocks } from "../../views/account-connection"; import { alreadyLinkedModalView, createLinkIssueModalView } from "../../views/link-issue-modal"; -import { E_MESSAGE_ACTION_TYPES } from "../../types/types"; -import { getAPIClient } from "@/services/client"; -import { E_INTEGRATION_KEYS } from "@plane/etl/core"; -import { ENTITIES } from "../../helpers/constants"; const apiClient = getAPIClient(); @@ -149,6 +149,7 @@ const handleCreateNewWorkItem = async (data: TMessageActionPayload) => { message: { text: data.message.text, ts: data.message.ts, + blocks: data.message.blocks, }, channel: { id: data.channel.id, diff --git a/silo/src/apps/slack/worker/handlers/view-submission.ts b/silo/src/apps/slack/worker/handlers/view-submission.ts index c9319951bb..1c65b79817 100644 --- a/silo/src/apps/slack/worker/handlers/view-submission.ts +++ b/silo/src/apps/slack/worker/handlers/view-submission.ts @@ -1,17 +1,18 @@ import { E_INTEGRATION_KEYS } from "@plane/etl/core"; import { TSlackIssueEntityData, TViewSubmissionPayload } from "@plane/etl/slack"; +import { IssueWithExpanded } from "@plane/sdk"; +import { env } from "@/env"; import { CONSTANTS } from "@/helpers/constants"; +import { downloadFile } from "@/helpers/utils"; import { logger } from "@/logger"; import { getAPIClient } from "@/services/client"; import { getConnectionDetails } from "../../helpers/connection-details"; import { ENTITIES } from "../../helpers/constants"; +import { getSlackContentParser } from "../../helpers/content-parser"; import { parseIssueFormData, parseLinkWorkItemFormData } from "../../helpers/parse-issue-form"; -import { E_MESSAGE_ACTION_TYPES, SlackPrivateMetadata, TSlackConnectionDetails } from "../../types/types"; +import { E_MESSAGE_ACTION_TYPES, SlackPrivateMetadata, TSlackConnectionDetails, TSlackWorkspaceConnectionConfig } from "../../types/types"; import { createSlackLinkback } from "../../views/issue-linkback"; import { createLinkIssueModalView } from "../../views/link-issue-modal"; -import { env } from "@/env"; -import { IssueWithExpanded } from "@plane/sdk"; -import { credentials } from "amqplib"; const apiClient = getAPIClient(); @@ -285,10 +286,10 @@ export const handleCreateNewWorkItemViewSubmission = async ( details: TSlackConnectionDetails, data: TViewSubmissionPayload ) => { - const { workspaceConnection, slackService, planeClient } = details; + const { workspaceConnection, slackService, planeClient, botCredentials } = details; try { - const parsedData = parseIssueFormData(data.view.state.values); + const parsedData = await parseIssueFormData(data.view.state.values); const metadata = JSON.parse(data.view.private_metadata) as SlackPrivateMetadata; if ( @@ -298,14 +299,35 @@ export const handleCreateNewWorkItemViewSubmission = async ( logger.info(`[SLACK] Unsupported payload type: ${metadata.entityType}`); return; } + const config = workspaceConnection.config as TSlackWorkspaceConnectionConfig const slackUser = await slackService.getUserInfo(data.user.id); const members = await planeClient.users.listAllUsers(workspaceConnection.workspace_slug); const member = members.find((m: any) => m.email === slackUser?.user.profile.email); + const userMap = new Map() + config.userMap?.forEach((user) => { + userMap.set(user.slackUser, user.planeUserId) + }) + + const parser = getSlackContentParser({ + userMap, + teamDomain: data.team.domain + }) + + let parsedDescription: string; + + try { + parsedDescription = await parser.toPlaneHtml(parsedData.description ?? "

    "); + } catch (error) { + logger.error("[SLACK] Error parsing issue description:", error); + // Fallback to the original description or a safe default + parsedDescription = parsedData.description ?? "

    "; + } + const issue = await planeClient.issue.create(workspaceConnection.workspace_slug, parsedData.project, { name: parsedData.title, - description_html: parsedData.description == null ? "

    " : parsedData.description, + description_html: parsedDescription, created_by: member?.id, state: parsedData.state, priority: parsedData.priority, @@ -339,11 +361,12 @@ export const handleCreateNewWorkItemViewSubmission = async ( slackService, apiClient, workspaceConnection, + planeClient, parsedData, metadata as SlackPrivateMetadata, linkBack, issue, - credentials + botCredentials ); } else if (metadata.entityType === ENTITIES.COMMAND_PROJECT_SELECTION) { /* For command project selection, @@ -373,12 +396,43 @@ async function handleShortcutProjectSelection( slackService: any, apiClient: any, workspaceConnection: any, + planeClient: any, parsedData: any, metadata: SlackPrivateMetadata, linkBack: any, issue: any, credentials: any ) { + if (metadata.entityPayload.message.ts) { + const response = await slackService.getMessage( + metadata.entityPayload.channel.id, + metadata.entityPayload.message?.ts + ); + + if (response && response.ok && response.messages && response.messages.length > 0) { + const message = response.messages[0]; + if (message.files && credentials.source_access_token) { + const fileUploadPromises = message.files.map(async (file: any) => { + const blob = await downloadFile(file.url_private_download, `Bearer ${credentials.source_access_token}`); + if (blob) { + await planeClient.issue.uploadAttachment( + workspaceConnection.workspace_slug, + parsedData.project, + issue.id, + blob as File, + file.name, + file.size, + { + type: file.mimetype, + } + ); + } + }); + await Promise.all(fileUploadPromises); + } + } + } + // Send thread message const res = await slackService.sendThreadMessage( metadata.entityPayload.channel.id, diff --git a/silo/src/apps/slack/worker/plane-webhook-handlers/handle-comment-webhook.ts b/silo/src/apps/slack/worker/plane-webhook-handlers/handle-comment-webhook.ts index 33f592c850..2c2f0af23d 100644 --- a/silo/src/apps/slack/worker/plane-webhook-handlers/handle-comment-webhook.ts +++ b/silo/src/apps/slack/worker/plane-webhook-handlers/handle-comment-webhook.ts @@ -1,10 +1,11 @@ +import TurndownService from "turndown"; import { TSlackIssueEntityData } from "@plane/etl/slack"; import { WebhookIssueCommentPayload } from "@plane/sdk"; -import { getAPIClient } from "@/services/client"; -import { getConnectionDetailsForIssue } from "../../helpers/connection-details"; +import { env } from "@/env"; import { logger } from "@/logger"; - -const apiClient = getAPIClient(); +import { getConnectionDetailsForIssue } from "../../helpers/connection-details"; +import { getPlaneContentParser } from "../../helpers/content-parser"; +import { TSlackWorkspaceConnectionConfig } from "../../types/types"; export const handleIssueCommentWebhook = async (payload: WebhookIssueCommentPayload) => { await handleCommentSync(payload); @@ -14,19 +15,22 @@ const handleCommentSync = async (payload: WebhookIssueCommentPayload) => { const data = payload as unknown as WebhookIssueCommentPayload["created"]; // Return the comment if it's already connected to any exisitng connection - if (data.data.external_id !== null) return + if (data.data.external_id !== null) return; logger.info("Received issue comment webhook", { payload: data, }); - const details = await getConnectionDetailsForIssue({ - id: data.data.issue, - workspace: data.data.workspace, - project: data.data.project, - issue: data.data.issue, - event: payload.event, - }, data.data.created_by); + const details = await getConnectionDetailsForIssue( + { + id: data.data.issue, + workspace: data.data.workspace, + project: data.data.project, + issue: data.data.issue, + event: payload.event, + }, + data.data.created_by + ); if (!details) { logger.error("No details found for issue comment webhook", { @@ -35,25 +39,48 @@ const handleCommentSync = async (payload: WebhookIssueCommentPayload) => { return; } - const { isUser, entityConnection, slackService } = details; + const { isUser, entityConnection, slackService, workspaceConnection } = details; const slackData = entityConnection.entity_data as TSlackIssueEntityData; const channel = slackData.channel; - let comment = data.data.comment_stripped; + const config = workspaceConnection.config as TSlackWorkspaceConnectionConfig + + const userMap = new Map() + for (const user of config.userMap ?? []) { + userMap.set(user.planeUserId, user.slackUser) + } + + const parser = getPlaneContentParser({ + appBaseUrl: env.APP_BASE_URL, + workspaceSlug: workspaceConnection.workspace_slug, + userMap, + }) + const comment = await parser.toPlaneHtml(data.data.comment_html); + const turndown = new TurndownService({ + headingStyle: "atx", + bulletListMarker: "-", + codeBlockStyle: "fenced", + emDelimiter: "_", + strongDelimiter: "**", + linkStyle: "inlined", + }) + + turndown.addRule("link", { + filter: (node) => node.tagName === "A", + replacement: (content, node) => `<${(node as Element).getAttribute("href")}|${content}>` + }) + + let markdown = turndown.turndown(comment) // If we don't have the credentials of the user, in that case we'll add the user's information to the comment if (!isUser && payload.activity.actor) { const displayName = payload.activity.actor.display_name; - comment = `*From ${displayName}*\n\n${comment}`; + markdown = `*From ${displayName}*\n\n${markdown}`; } - const response = await slackService.sendThreadMessage( - channel, - entityConnection.entity_id ?? "", - comment - ); + const response = await slackService.sendThreadMessage(channel, entityConnection.entity_id ?? "", markdown); logger.info("Slack message sent", { slackMessageId: response.ts,