mirror of
https://github.com/makeplane/plane.git
synced 2026-07-13 05:49:40 +02:00
[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:
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export * from "./image-proxy-extension"
|
||||
export * from "./plane-mentions-parser"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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,
|
||||
|
||||
74
silo/src/apps/slack/helpers/content-parser/index.ts
Normal file
74
silo/src/apps/slack/helpers/content-parser/index.ts
Normal 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, [], [])
|
||||
}
|
||||
175
silo/src/apps/slack/helpers/content-parser/parser.test.ts
Normal file
175
silo/src/apps/slack/helpers/content-parser/parser.test.ts
Normal 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>');
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
}];
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
);
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user