[SILO-295] [SILO-292] feat: added support for rich text and mention sync across slack commands and comments (#3489)

* feat: made mentions parser to be compatible with multiple symbols

* feat: addedd plane mentions parser to parse mentions from plane html

* feat: returned workspace connection information from plane connection details

* feat: added custom issue rich text parsing block in slack issue modal

* feat: added content parser for slack integration

* feat: added view submission to address rich text blocks

* feat: added content parsing for plane comments

* feat: added turndown service

* feat: added file download as attachment when create issues from slack message

* fix: resolved comments

* fix: renamed slack content parser

* fix: added test for content parser
This commit is contained in:
Henit Chobisa
2025-07-01 14:23:56 +05:30
committed by GitHub
parent ba128e12d3
commit 08df65f79d
18 changed files with 846 additions and 156 deletions

View File

@@ -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({

View File

@@ -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<string, string>;
// The symbol to use for mentions
mentionSymbol?: string;
// Entity map to map mentions to entities
entityMap: Map<string, string>;
// 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<HTMLElement> {
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 = `<a href="${url}">${label}</a>`;
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;

View File

@@ -1 +1,2 @@
export * from "./image-proxy-extension"
export * from "./plane-mentions-parser"

View File

@@ -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<HTMLElement> {
// 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;
}
}

View File

@@ -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",

View File

@@ -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));

View File

@@ -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,

View File

@@ -0,0 +1,74 @@
import { ContentParser, ExternalMentionParserExtension, IParserExtension, PlaneMentionParserExtension } from "@plane/etl/parser";
export type TSlackContentParserConfig = {
userMap: Map<string, string>
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<string, string>(), // 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<string, string>(),
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<string, string> // 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, [], [])
}

View File

@@ -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 id="[a-f0-9-]{36}" entity_identifier="plane-user-id-1" entity_name="user_mention"><\/mention-component>/
);
expect(result).toMatch(
/<mention-component id="[a-f0-9-]{36}" entity_identifier="plane-user-id-2" entity_name="user_mention"><\/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('<strong>bold text</strong>');
expect(result).toContain('<em>italic text</em>');
expect(result).toContain('<code>inline code</code>');
// Test list structure
expect(result).toContain('<ul>');
expect(result).toContain('<li>First item</li>');
expect(result).toContain('<li>Second item</li>');
expect(result).toContain('<li>Third item</li>');
expect(result).toContain('</ul>');
});
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 id="[a-f0-9-]{36}" entity_identifier="plane-user-id-1" entity_name="user_mention"><\/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('<strong>Bold text</strong>');
expect(result).toContain('<em>italic text</em>');
expect(result).toContain('<code>Code snippet</code>');
expect(result).toContain('<blockquote>\n<p>Quote block</p>\n</blockquote>');
expect(result).toContain('<li>List item 1</li>');
expect(result).toContain('<li>List item 2</li>');
});
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 id="[a-f0-9-]{36}" entity_identifier="plane-user-id-1" entity_name="user_mention"><\/mention-component>/
);
expect(result).toContain("Slack Channel: dev-team");
expect(result).toContain("Slack Broadcast: here");
expect(result).toContain('<a href="https://docs.plane.so">https://docs.plane.so</a>');
});
});

View File

@@ -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<ParsedIssueData> => {
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(`<!date^${element.timestamp}^${element.format}|${element.url}>`, element.style)
}
return applyMrkdwnStyle(`<!date^${element.timestamp}^${element.format}>`, 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 <url|text> links and converts them to rich text elements
*/
export const parseSlackLinksToRichText = (text: string) => {
if (!text) return [];
const elements: any[] = [];
// Match both <url|text> and <url> 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,
}];
}

View File

@@ -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;
}
);
);

View File

@@ -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,
},
},
],
});

View File

@@ -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;
}

View File

@@ -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,
},

View File

@@ -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<string, string>();
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: `<p>${data.event.text}</p>`,
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 {

View File

@@ -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,

View File

@@ -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<string, string>()
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 ?? "<p></p>");
} catch (error) {
logger.error("[SLACK] Error parsing issue description:", error);
// Fallback to the original description or a safe default
parsedDescription = parsedData.description ?? "<p></p>";
}
const issue = await planeClient.issue.create(workspaceConnection.workspace_slug, parsedData.project, {
name: parsedData.title,
description_html: parsedData.description == null ? "<p></p>" : 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<typeof ENTITIES.SHORTCUT_PROJECT_SELECTION>,
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<typeof ENTITIES.SHORTCUT_PROJECT_SELECTION>,
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,

View File

@@ -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<string, string>()
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,