diff --git a/apps/silo/src/apps/slack/controllers/index.ts b/apps/silo/src/apps/slack/controllers/index.ts index c66d7298b2..45e29e29f6 100644 --- a/apps/silo/src/apps/slack/controllers/index.ts +++ b/apps/silo/src/apps/slack/controllers/index.ts @@ -1,14 +1,14 @@ import { Request, RequestHandler, Response } from "express"; -import { validate as uuidValidate } from "uuid"; import { E_ENTITY_CONNECTION_KEYS, E_INTEGRATION_KEYS, E_SILO_ERROR_CODES } from "@plane/etl/core"; import { E_SLACK_ENTITY_TYPE, isUserMessage, SlackAuthState, SlackPlaneOAuthState, - SlackUserAuthState, TBlockSuggestionPayload, + SlackUserAuthState, + TBlockSuggestionPayload, TSlackCommandPayload, - TSlackPayload + TSlackPayload, } from "@plane/etl/slack"; import { E_PLANE_WEBHOOK_ACTION, @@ -30,6 +30,7 @@ import { EOAuthGrantType, ESourceAuthorizationType } from "@/types/oauth"; import { integrationTaskManager } from "@/worker"; import { Store } from "@/worker/base"; import { authenticateSlackRequestMiddleware, slackAuth } from "../auth/auth"; +import { isValidIssueUpdateActivity } from "../helpers/activity"; import { getConnectionDetails, updateUserMap } from "../helpers/connection-details"; import { ACTIONS } from "../helpers/constants"; import { convertToSlackOptions } from "../helpers/slack-options"; @@ -673,7 +674,6 @@ export default class SlackController { @Middleware(authenticateSlackRequestMiddleware as RequestHandler) async slackOptions(req: Request, res: Response) { try { - const payload = JSON.parse(req.body.payload) as TBlockSuggestionPayload; if (payload.type === "block_suggestion" && payload.action_id && payload.action_id === ACTIONS.LINK_WORK_ITEM) { @@ -712,7 +712,7 @@ export default class SlackController { const [projectId, fieldId] = values; const details = await getConnectionDetails(payload.team.id, { - id: payload.user.id + id: payload.user.id, }); if (!details) { logger.info(`[SLACK] No connection details found for team ${payload.team.id}`); @@ -727,14 +727,12 @@ export default class SlackController { } const formUtilsService = getFormUtilsService(credentials.target_access_token); - const optionsResponse = await formUtilsService.getOptionsForEntity( - { - slug: workspaceConnection.workspace_slug, - projectId: projectId, - typeIdentifier: fieldId, - searchText: payload.value, - } - ); + const optionsResponse = await formUtilsService.getOptionsForEntity({ + slug: workspaceConnection.workspace_slug, + projectId: projectId, + typeIdentifier: fieldId, + searchText: payload.value, + }); const options = optionsResponse.map((option) => ({ text: { @@ -748,7 +746,6 @@ export default class SlackController { return res.status(200).json({ options: options, }); - } catch (error) { return responseHandler(res, 500, error); } @@ -757,8 +754,6 @@ export default class SlackController { @Post("/plane/events") async planeEvents(req: Request, res: Response) { try { - const isUUID = (id: string | null) => id && uuidValidate(id); - const payload = req.body as PlaneWebhookPayloadBase; const id = payload.data.id; const workspace = payload.data.workspace; @@ -832,12 +827,7 @@ export default class SlackController { }, Number(env.DEDUP_INTERVAL) ); - } else if ( - payload.activity.field && - !payload.activity.field.includes("_id") && - !isUUID(payload.activity.old_value) && - !isUUID(payload.activity.new_value) - ) { + } else if (isValidIssueUpdateActivity(payload)) { const [entityConnection] = await apiClient.workspaceEntityConnection.listWorkspaceEntityConnections({ workspace_id: workspace, project_id: project, diff --git a/apps/silo/src/apps/slack/helpers/activity.ts b/apps/silo/src/apps/slack/helpers/activity.ts index 9c4f1dcc97..e465f824c4 100644 --- a/apps/silo/src/apps/slack/helpers/activity.ts +++ b/apps/silo/src/apps/slack/helpers/activity.ts @@ -1,18 +1,149 @@ +import { PlaneWebhookPayloadBase, ExIssue, ExIssueComment } from "@plane/sdk"; import { getIssueUrlFromSequenceId } from "@/helpers/urls"; +import { isUUID, titleCase } from "@/helpers/utils"; import { ActivityForSlack } from "../types/types"; import { getUserMarkdown } from "./user"; +type ActivityFormatterContext = { + workspaceSlug: string; + userMap: Map; + activity: ActivityForSlack; + fieldText: string; // Pre-formatted field name +}; + +type ActivityFormatter = (context: ActivityFormatterContext) => string; + +// Define custom formatters for specific fields +const ACTIVITY_FORMATTERS: Record = { + /** + * Formats assignee changes showing added/removed users with mentions + * @example + * // Returns: + * // > *Assignees*: + * // > - Added <@U1234|john.doe>, <@U5678|jane.smith> + * // > - Removed <@U9999|old.user> + */ + assignees: ({ activity, fieldText, userMap, workspaceSlug }) => { + if (!activity.isArrayField) return ""; + + let changeText = `> *${fieldText}*:\n`; + + if (activity.addedIdentifiers?.length > 0) { + const addedUsers = activity.addedIdentifiers.map((id, index) => + getUserMarkdown(userMap, workspaceSlug, id, activity.added[index]) + ).join(", "); + changeText += `> - Added ${addedUsers}\n`; + } + + if (activity.removedIdentifiers?.length > 0) { + const removedUsers = activity.removedIdentifiers.map((id, index) => + getUserMarkdown(userMap, workspaceSlug, id, activity.removed[index]) + ).join(", "); + changeText += `> - Removed ${removedUsers}\n`; + } + + return changeText; + }, + + /** + * Formats parent issue changes with URL linking to the parent issue + * @example + * // Returns: + * // "Added parent " + * // or for invalid format: + * // "Added parent INVALID-ID" + */ + parent: ({ activity, workspaceSlug }) => { + if (activity.isArrayField) return ""; + + if (!activity.newValue) return ""; + + const parts = activity.newValue.split("-"); + if (parts.length === 2) { + const projectId = parts[0]; + const issueId = parts[1]; + return `Added parent <${getIssueUrlFromSequenceId(workspaceSlug, projectId, issueId)}|${activity.newValue}>`; + } + + return `Added parent ${activity.newValue}`; + }, + + /** + * Formats priority changes with title case formatting and strikethrough for old values + * @example + * // Returns: + * // "> *Priority*: ~High~ → Medium\n" (when changing priority) + * // "> *Priority*: removed ~Low~\n" (when removing priority) + * // "> *Priority*: High\n" (when setting new priority) + */ + priority: ({ activity, fieldText }) => { + + if (activity.isArrayField) return ""; + + const oldValue = activity.oldValue ? titleCase(activity.oldValue) : null; + const newValue = activity.newValue ? titleCase(activity.newValue) : null; + + if (oldValue && newValue) { + return `> *${fieldText}*: ~${oldValue}~ → ${newValue}\n`; + } else if (oldValue && !newValue) { + return `> *${fieldText}*: removed ~${oldValue}~\n`; + } + + return `> *${fieldText}*: ${newValue}\n`; + }, +}; + +// Default formatters for array and single fields +/** + * Default formatter for array fields showing added/removed items + * @example + * // Returns: + * // > *Labels*: + * // > - Added _bug, critical_ + * // > - Removed _enhancement_ + */ +const formatArrayFieldDefault = ({ activity, fieldText }: ActivityFormatterContext): string => { + if (!activity.isArrayField) return ""; + if (activity.added.length === 0 && activity.removed.length === 0) return ""; + + let changeText = `> *${fieldText}*:\n`; + + if (activity.added.length > 0) { + changeText += `> - Added _${activity.added.join(", ")}_\n`; + } + + if (activity.removed.length > 0) { + changeText += `> - Removed _${activity.removed.join(", ")}_\n`; + } + + return changeText; +}; + +/** + * Default formatter for single fields with old/new value changes + * @example + * // Returns: + * // "> *Status*: ~In Progress~ → Done\n" (when changing value) + * // "> *Status*: removed ~Done~\n" (when removing value) + * // "> *Status*: In Progress\n" (when setting new value) + */ +const formatSingleFieldDefault = ({ activity, fieldText }: ActivityFormatterContext): string => { + if (activity.isArrayField) return ""; + + if (activity.oldValue && activity.newValue) { + return `> *${fieldText}*: ~${activity.oldValue}~ → ${activity.newValue}\n`; + } else if (activity.oldValue && !activity.newValue) { + return `> *${fieldText}*: removed ~${activity.oldValue}~\n`; + } + + return `> *${fieldText}*: ${activity.newValue}\n`; +}; + export const formatActivityValue = ( workspaceSlug: string, userMap: Map, activity: ActivityForSlack ) => { - const titleCaseWord = (word: string) => - word - .split(" ") - .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) - .join(" "); - const formatFieldName = (fieldName: string) => fieldName .replace(/[-_]/g, " ") @@ -20,53 +151,30 @@ export const formatActivityValue = ( .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) .join(" "); - let changeText = ""; const fieldText = formatFieldName(activity.field); + const context: ActivityFormatterContext = { + workspaceSlug, + userMap, + activity, + fieldText, + }; - if (activity.isArrayField === true) { - if (activity.added.length > 0 || activity.removed.length > 0) { - changeText += `> *${fieldText}*:\n`; - if (activity.added.length > 0) { - changeText += `> - Added _${activity.added.join(", ")}_\n`; - } - if (activity.removed.length > 0) { - changeText += `> - Removed _${activity.removed.join(", ")}_\n`; - } - } + // Check if we have a custom formatter for this field + const customFormatter = ACTIVITY_FORMATTERS[activity.field]; + if (customFormatter) { + return customFormatter(context); + } - return changeText; + // Fall back to default formatters + if (activity.isArrayField) { + return formatArrayFieldDefault(context); } else { - if (activity.field === "reaction") { - const emoji = String.fromCodePoint(parseInt(activity.newValue)); - return `${getUserMarkdown(userMap, workspaceSlug, activity.actorId, activity.actorDisplayName)} reacted with ${emoji}`; - } - - if (activity.field === "parent" && activity.newValue) { - const parts = activity.newValue.split("-"); - if (parts.length === 2) { - const projectId = parts[0]; - const issueId = parts[1]; - return `Added parent <${getIssueUrlFromSequenceId(workspaceSlug, projectId, issueId)}|${activity.newValue}>`; - } - - return `Added parent ${activity.newValue}`; - } - - if (activity.field === "priority") { - if (activity.oldValue) { - activity.oldValue = titleCaseWord(activity.oldValue); - } - if (activity.newValue) { - activity.newValue = titleCaseWord(activity.newValue); - } - } - - if (activity.oldValue && activity.newValue) { - return `> *${fieldText}*: ~${activity.oldValue}~ → ${activity.newValue}\n`; - } else if (activity.oldValue && !activity.newValue) { - return `> *${fieldText}*: removed ~${activity.oldValue}~\n`; - } - - return `> *${fieldText}*: ${activity.newValue}\n`; + return formatSingleFieldDefault(context); } }; + +export const isValidIssueUpdateActivity = (payload: PlaneWebhookPayloadBase) => + payload.activity.field && + !payload.activity.field.includes("_id") && + !isUUID(payload.activity.old_value) && + !isUUID(payload.activity.new_value); diff --git a/apps/silo/src/apps/slack/helpers/blocks.ts b/apps/silo/src/apps/slack/helpers/blocks.ts index 6a5b8a02b7..de286e5465 100644 --- a/apps/silo/src/apps/slack/helpers/blocks.ts +++ b/apps/silo/src/apps/slack/helpers/blocks.ts @@ -1,20 +1,26 @@ -import { ExIntakeIssue, IssueWithExpanded } from "@plane/sdk"; - -import { getUserMarkdown } from "./user"; export enum E_MUTATION_CONTEXT_ITEM_TYPE { WORK_ITEM = "work item", - INTAKE = "intake" + INTAKE = "intake", } export enum E_MUTATION_CONTEXT_FORMAT_TYPE { CREATION_ONLY = "creation-only", CREATION_AND_UPDATE = "creation-and-update", - UPDATE_ONLY = "update-only" + UPDATE_ONLY = "update-only", } export const createSlackLinkbackMutationContext = (params: { - issue: Pick, "created_by" | "updated_by">; + issueCtx: { + createdBy?: { + id: string; + display_name: string; + }; + updatedBy?: { + id: string; + display_name: string; + }; + }; planeToSlackUserMap: Map; workspaceSlug: string; options?: { @@ -23,7 +29,7 @@ export const createSlackLinkbackMutationContext = (params: { format?: E_MUTATION_CONTEXT_FORMAT_TYPE; }; }) => { - const { issue, planeToSlackUserMap, workspaceSlug, options = {} } = params; + const { issueCtx, options = {} } = params; const { itemType = E_MUTATION_CONTEXT_ITEM_TYPE.WORK_ITEM, showUpdateInfo = false, @@ -32,28 +38,26 @@ export const createSlackLinkbackMutationContext = (params: { // Handle update-only format if (format === E_MUTATION_CONTEXT_FORMAT_TYPE.UPDATE_ONLY) { - if (!issue.updated_by) { + if (!issueCtx.updatedBy) { return ""; // Return empty string if no update info available } - const updateUser = getUserMarkdown(planeToSlackUserMap, workspaceSlug, issue.updated_by); - return `*${updateUser} updated this ${itemType}*`; + const updateUser = issueCtx.updatedBy.display_name; + return `_${updateUser}_ updated this ${itemType}`; } // Determine the user to display (prefer created_by, fallback to updated_by) - const user = issue.created_by - ? getUserMarkdown(planeToSlackUserMap, workspaceSlug, issue.created_by) - : issue.updated_by - ? getUserMarkdown(planeToSlackUserMap, workspaceSlug, issue.updated_by) + const user = issueCtx.createdBy + ? issueCtx.createdBy.display_name + : issueCtx.updatedBy + ? issueCtx.updatedBy.display_name : "Unknown User"; - - let content = `*${user} ${itemType === E_MUTATION_CONTEXT_ITEM_TYPE.INTAKE ? "created" : "added"} this ${itemType}*`; + let content = `_${user}_ ${itemType === E_MUTATION_CONTEXT_ITEM_TYPE.INTAKE ? "created" : "added"} this ${itemType}`; // Add update information if requested and available - if (format === E_MUTATION_CONTEXT_FORMAT_TYPE.CREATION_AND_UPDATE && showUpdateInfo && issue.updated_by) { - const updateUser = getUserMarkdown(planeToSlackUserMap, workspaceSlug, issue.updated_by); - content += `\n*${updateUser} updated this ${itemType}*`; + if (format === E_MUTATION_CONTEXT_FORMAT_TYPE.CREATION_AND_UPDATE && showUpdateInfo && issueCtx.updatedBy) { + content += `\n_${issueCtx.updatedBy.display_name}_ updated this ${itemType}`; } return content; diff --git a/apps/silo/src/apps/slack/helpers/constants.ts b/apps/silo/src/apps/slack/helpers/constants.ts index ba495e33d5..a0ca767173 100644 --- a/apps/silo/src/apps/slack/helpers/constants.ts +++ b/apps/silo/src/apps/slack/helpers/constants.ts @@ -79,5 +79,11 @@ export const INTAKE_STATUSES = [ { id: 2, name: "Duplicate", emoji: "🔄" }, ]; - -export const IGNORED_FIELD_UPDATES = ["description", "attachment", "sort_order", "link"] +export const IGNORED_FIELD_UPDATES = [ + "description", + "description_html", + "attachment", + "sort_order", + "link", + "reaction", +]; diff --git a/apps/silo/src/apps/slack/helpers/linkback.ts b/apps/silo/src/apps/slack/helpers/linkback.ts index 7677967aad..782689469c 100644 --- a/apps/silo/src/apps/slack/helpers/linkback.ts +++ b/apps/silo/src/apps/slack/helpers/linkback.ts @@ -1,28 +1,34 @@ -import { TSlackConnectionDetails } from "../types/types" -import { createSlackLinkback } from "../views/issue-linkback" -import { getUserMapFromSlackWorkspaceConnection } from "./user" - +import { TSlackConnectionDetails } from "../types/types"; +import { createSlackLinkback } from "../views/issue-linkback"; +import { enhanceUserMapWithSlackLookup, getUserMapFromSlackWorkspaceConnection } from "./user"; type TRefreshLinkbackProps = { - details: TSlackConnectionDetails - issueId: string - projectId: string -} + details: TSlackConnectionDetails; + issueId: string; + projectId: string; +}; export const refreshLinkback = async (props: TRefreshLinkbackProps) => { const { details, issueId, projectId } = props; - const { workspaceConnection, planeClient } = details; + const { workspaceConnection, planeClient, slackService } = details; const issue = await planeClient.issue.getIssueWithFields(workspaceConnection.workspace_slug, projectId, issueId, [ "state", "project", "assignees", "labels", + "created_by", + "updated_by", ]); const userMap = getUserMapFromSlackWorkspaceConnection(workspaceConnection); + const enhancedUserMap = await enhanceUserMapWithSlackLookup({ + planeUsers: issue.assignees, + currentUserMap: userMap, + slackService, + }); - const updatedLinkback = createSlackLinkback(workspaceConnection.workspace_slug, issue, userMap, false); + const updatedLinkback = createSlackLinkback(workspaceConnection.workspace_slug, issue, enhancedUserMap, false); return updatedLinkback; -} +}; diff --git a/apps/silo/src/apps/slack/helpers/modal.ts b/apps/silo/src/apps/slack/helpers/modal.ts index 555ae8ffbf..51f83cf695 100644 --- a/apps/silo/src/apps/slack/helpers/modal.ts +++ b/apps/silo/src/apps/slack/helpers/modal.ts @@ -223,7 +223,7 @@ async function mapFieldToSlackBlock( field: FormField, projectId: string, metadata?: SlackPrivateMetadata, - workItem?: Partial> + workItem?: Partial> ) { if (field.id === E_KNOWN_FIELD_KEY.DESCRIPTION_HTML && metadata) { if (workItem) { @@ -268,7 +268,7 @@ async function getSlackBlocks( }, type: "work-item" | "intake", metadata?: SlackPrivateMetadata, - workItem?: Partial> + workItem?: Partial> ) { const { workspaceSlug, projectId, issueTypeId, accessToken } = params; diff --git a/apps/silo/src/apps/slack/helpers/user.ts b/apps/silo/src/apps/slack/helpers/user.ts index 62788b6e7f..c1e9ccd85c 100644 --- a/apps/silo/src/apps/slack/helpers/user.ts +++ b/apps/silo/src/apps/slack/helpers/user.ts @@ -1,9 +1,12 @@ +import { SlackService } from "@plane/etl/slack"; import { TWorkspaceConnection } from "@plane/types"; import { getUserProfileUrl } from "@/helpers/urls"; +import { invertStringMap } from "@/helpers/utils"; +import { logger } from "@/logger"; import { TSlackWorkspaceConnectionConfig } from "../types/types"; -export const getUserMarkdown = (planeToSlackUserMap: Map, workspaceSlug: string, userId: string, displayName?: string) => { - if (planeToSlackUserMap.has(userId)) { +export const getUserMarkdown = (planeToSlackUserMap: Map, workspaceSlug: string, userId: string, displayName?: string, disableMention: boolean = false) => { + if (planeToSlackUserMap.has(userId) && !disableMention) { return `<@${planeToSlackUserMap.get(userId)}>`; } return `<${getUserProfileUrl(workspaceSlug, userId)}|${displayName ?? "Plane User"}>`; @@ -28,3 +31,49 @@ export const getUserMapFromSlackWorkspaceConnection = (workspaceConnection: TWor }); return userMap; }; + +/** + * Enhance userMap by finding Slack users for Plane users who aren't mapped yet + */ +export const enhanceUserMapWithSlackLookup = async (props: { + planeUsers: Array<{ id: string; email?: string; display_name: string }>, + currentUserMap: Map, // slack -> plane mapping + slackService: SlackService, +}): Promise> => { + const { planeUsers, currentUserMap, slackService } = props; + + const planeToSlackMap = invertStringMap(currentUserMap); + const enhancedMap = new Map(currentUserMap); + + // Find Plane users who aren't in the current userMap + const unmappedUsers = planeUsers.filter(user => + user.email && !planeToSlackMap.has(user.id) + ); + + if (unmappedUsers.length === 0) { + return enhancedMap; + } + + try { + // Match unmapped Plane users with Slack users by email + for (const planeUser of unmappedUsers) { + + if (!planeUser.email) { + logger.warn("Skipping user lookup for user without email", { userId: planeUser.id }); + continue; + } + + const slackUser = await slackService.userLookupByEmail(planeUser.email); + + if (slackUser.ok) { + enhancedMap.set(slackUser.user.id, planeUser.id); + } else { + logger.warn("Failed to lookup Slack user by email", { email: planeUser.email, error: slackUser.error }); + } + } + } catch (error) { + logger.warn("Failed to enhance user map with Slack lookups", { error }); + } + + return enhancedMap; +}; diff --git a/apps/silo/src/apps/slack/types/types.ts b/apps/silo/src/apps/slack/types/types.ts index b23afd0daa..fc1427caa7 100644 --- a/apps/silo/src/apps/slack/types/types.ts +++ b/apps/silo/src/apps/slack/types/types.ts @@ -107,7 +107,7 @@ export type TSlackWorkItemOrIntakeModalParams = { showThreadSync?: boolean; // Work item to update - workItem?: Partial> + workItem?: Partial> disableIssueType?: boolean; // Connection details @@ -141,10 +141,14 @@ export type ActivityForSlack = { isArrayField: true; removed: string[]; added: string[]; + addedIdentifiers: string[]; + removedIdentifiers: string[]; } | { isArrayField: false; newValue: string; oldValue?: string; + newIdentifier?: string; + oldIdentifier?: string; } ); diff --git a/apps/silo/src/apps/slack/views/activity.ts b/apps/silo/src/apps/slack/views/activity.ts index 19a1b7a162..632d0d8ac6 100644 --- a/apps/silo/src/apps/slack/views/activity.ts +++ b/apps/silo/src/apps/slack/views/activity.ts @@ -60,7 +60,6 @@ export const createActivityLinkback = (activity: ActivityProps) => { for (const [_, actorActivities] of Object.entries(activitiesByActor)) { let changeText = ""; const actorId = actorActivities[0].actorId; - const actorDisplayName = actorActivities[0].actorDisplayName; for (const activity of actorActivities) { const { field } = activity; @@ -86,9 +85,11 @@ export const createActivityLinkback = (activity: ActivityProps) => { }); const mutationContext = createSlackLinkbackMutationContext({ - issue: { - updated_by: actorId, - created_by: actorId, + issueCtx: { + updatedBy: { + id: actorId, + display_name: actorActivities[0].actorDisplayName, + }, }, planeToSlackUserMap, workspaceSlug, diff --git a/apps/silo/src/apps/slack/views/intake-linkback.ts b/apps/silo/src/apps/slack/views/intake-linkback.ts index 924404a79c..2ffaba4bbd 100644 --- a/apps/silo/src/apps/slack/views/intake-linkback.ts +++ b/apps/silo/src/apps/slack/views/intake-linkback.ts @@ -1,4 +1,4 @@ -import { ExIntakeIssue } from "@plane/sdk"; +import { ExIntakeIssue, PlaneUser } from "@plane/sdk"; import { getIntakeUrl } from "@/helpers/urls"; import { invertStringMap } from "@/helpers/utils"; import { createSlackLinkbackMutationContext, E_MUTATION_CONTEXT_FORMAT_TYPE, E_MUTATION_CONTEXT_ITEM_TYPE } from "../helpers/blocks"; @@ -9,7 +9,8 @@ export const createSlackIntakeLinkback = ( workspaceSlug: string, issue: ExIntakeIssue, userMap: Map, - showLogo = false + showLogo = false, + createdBy: PlaneUser | undefined, ) => { const { issue_detail } = issue; const planeToSlackUserMap = invertStringMap(userMap); @@ -79,7 +80,13 @@ export const createSlackIntakeLinkback = ( // Build markdown content for creation/update info const mutationContext = createSlackLinkbackMutationContext({ - issue, + issueCtx: { + createdBy: createdBy ? { + id: createdBy.id, + display_name: createdBy.display_name, + } : undefined, + updatedBy: undefined, + }, planeToSlackUserMap, workspaceSlug, options: { diff --git a/apps/silo/src/apps/slack/views/issue-linkback.ts b/apps/silo/src/apps/slack/views/issue-linkback.ts index 3718f17ad5..7273bf0000 100644 --- a/apps/silo/src/apps/slack/views/issue-linkback.ts +++ b/apps/silo/src/apps/slack/views/issue-linkback.ts @@ -1,13 +1,17 @@ import { IssueWithExpanded } from "@plane/sdk"; import { getIssueUrlFromSequenceId } from "@/helpers/urls"; import { invertStringMap } from "@/helpers/utils"; -import { createSlackLinkbackMutationContext, E_MUTATION_CONTEXT_FORMAT_TYPE, E_MUTATION_CONTEXT_ITEM_TYPE } from "../helpers/blocks"; +import { + createSlackLinkbackMutationContext, + E_MUTATION_CONTEXT_FORMAT_TYPE, + E_MUTATION_CONTEXT_ITEM_TYPE, +} from "../helpers/blocks"; import { ACTIONS } from "../helpers/constants"; import { getUserMarkdown } from "../helpers/user"; export const createSlackLinkback = ( workspaceSlug: string, - issue: IssueWithExpanded<["state", "project", "assignees", "labels"]>, + issue: IssueWithExpanded<["state", "project", "assignees", "labels", "created_by", "updated_by"]>, userMap: Map, isSynced: boolean, hideActions: boolean = false @@ -25,28 +29,30 @@ export const createSlackLinkback = ( }); // Build markdown content for main section (fallback to mrkdwn for compatibility) - let sectionContent = `> Project: *${issue.project.name}*`; + let sectionContent = `> *Project*: ${issue.project.name}`; if (issue.state) { - sectionContent += `\n> State: *${issue.state.name}*`; + sectionContent += `\n> *State*: ${issue.state.name}`; } if (issue.priority && issue.priority !== "none") { - sectionContent += `\n> Priority: *${issue.priority}*`; + sectionContent += `\n> *Priority*: ${issue.priority}`; } if (issue.assignees.length > 0) { const assigneeLabel = issue.assignees.length > 1 ? "Assignees" : "Assignee"; const assignee = issue.assignees.length > 1 - ? issue.assignees.map((a) => getUserMarkdown(planeToSlackUserMap, workspaceSlug, a.id)).join(", ") - : getUserMarkdown(planeToSlackUserMap, workspaceSlug, issue.assignees[0].id); + ? issue.assignees + .map((a) => getUserMarkdown(planeToSlackUserMap, workspaceSlug, a.id, a.display_name)) + .join(", ") + : getUserMarkdown(planeToSlackUserMap, workspaceSlug, issue.assignees[0].id, issue.assignees[0].display_name); - sectionContent += `\n> ${assigneeLabel}: *${assignee}*`; + sectionContent += `\n> *${assigneeLabel}*: ${assignee}`; } if (issue.target_date) { - sectionContent += `\n> Target Date: *${issue.target_date}*`; + sectionContent += `\n> *Target Date*: ${issue.target_date}`; } // Main section with issue details using mrkdwn for compatibility @@ -65,7 +71,10 @@ export const createSlackLinkback = ( // Build markdown content for creation/update info const mutationContext = createSlackLinkbackMutationContext({ - issue, + issueCtx: { + createdBy: issue.created_by, + updatedBy: issue.updated_by, + }, planeToSlackUserMap, workspaceSlug, options: { diff --git a/apps/silo/src/apps/slack/views/link-issue-modal.ts b/apps/silo/src/apps/slack/views/link-issue-modal.ts index 5fc2d512d8..669e7a9d37 100644 --- a/apps/silo/src/apps/slack/views/link-issue-modal.ts +++ b/apps/silo/src/apps/slack/views/link-issue-modal.ts @@ -90,7 +90,7 @@ export const createLinkIssueModalView = ( */ export const alreadyLinkedModalView = ( workspaceSlug: string, - issue: IssueWithExpanded<["state", "project", "assignees", "labels"]>, + issue: IssueWithExpanded<["state", "project", "assignees", "labels", "created_by", "updated_by"]>, states: ExState[], userMap: Map, privateMetadata: any = {} diff --git a/apps/silo/src/apps/slack/worker/handlers/handle-message.ts b/apps/silo/src/apps/slack/worker/handlers/handle-message.ts index 4db378dfa2..eae887a910 100644 --- a/apps/silo/src/apps/slack/worker/handlers/handle-message.ts +++ b/apps/silo/src/apps/slack/worker/handlers/handle-message.ts @@ -8,7 +8,7 @@ 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 { getUserMapFromSlackWorkspaceConnection } from "../../helpers/user"; +import { enhanceUserMapWithSlackLookup, getUserMapFromSlackWorkspaceConnection } from "../../helpers/user"; import { TSlackConnectionDetails } from "../../types/types"; import { createCycleLinkback } from "../../views/cycle-linkback"; import { createSlackLinkback } from "../../views/issue-linkback"; @@ -140,13 +140,19 @@ export const handleLinkSharedEvent = async (data: SlackEventPayload) => { workspaceConnection.workspace_slug, resource.projectIdentifier, Number(resource.issueKey), - ["state", "project", "assignees", "labels", "type"], + ["state", "project", "assignees", "labels", "type", "created_by", "updated_by"], true ); + const enhancedUserMap = await enhanceUserMapWithSlackLookup({ + planeUsers: issue.assignees, + currentUserMap: userMap, + slackService, + }); + const hideActions = issue.type?.is_epic ?? false; - const linkBack = createSlackLinkback(workspaceConnection.workspace_slug, issue, userMap, false, hideActions); + const linkBack = createSlackLinkback(workspaceConnection.workspace_slug, issue, enhancedUserMap, false, hideActions); unfurlMap[link.url] = { blocks: linkBack.blocks, }; diff --git a/apps/silo/src/apps/slack/worker/handlers/message-action.ts b/apps/silo/src/apps/slack/worker/handlers/message-action.ts index 0a8815a150..78b17e6fd9 100644 --- a/apps/silo/src/apps/slack/worker/handlers/message-action.ts +++ b/apps/silo/src/apps/slack/worker/handlers/message-action.ts @@ -92,7 +92,7 @@ const handleLinkWorkItem = async (data: TMessageActionPayload) => { workspaceEntityConnections[0].workspace_slug, workspaceEntityConnections[0].project_id!, workspaceEntityConnections[0].issue_id!, - ["state", "project", "assignees", "labels"] + ["state", "project", "assignees", "labels", "created_by", "updated_by"] ); const statesPromise = planeClient.state.list( workspaceEntityConnections[0].workspace_slug, diff --git a/apps/silo/src/apps/slack/worker/handlers/view-submission.ts b/apps/silo/src/apps/slack/worker/handlers/view-submission.ts index c29c1082e0..25ae406b35 100644 --- a/apps/silo/src/apps/slack/worker/handlers/view-submission.ts +++ b/apps/silo/src/apps/slack/worker/handlers/view-submission.ts @@ -14,7 +14,7 @@ import { getSlackContentParser } from "../../helpers/content-parser"; import { createSlackFormParser } from "../../helpers/field-parser/field-parser"; import { parseLinkWorkItemFormData } from "../../helpers/parse-issue-form"; import { getSlackThreadUrl } from "../../helpers/urls"; -import { getUserMapFromSlackWorkspaceConnection } from "../../helpers/user"; +import { enhanceUserMapWithSlackLookup, getUserMapFromSlackWorkspaceConnection } from "../../helpers/user"; import { TIntakeFormResult, TWorkItemFormResult } from "../../types/fields"; import { E_MESSAGE_ACTION_TYPES, @@ -70,7 +70,7 @@ export const handleLinkWorkItemViewSubmission = async ( linkWorkItemValues.workspaceSlug, linkWorkItemValues.projectId, linkWorkItemValues.issueId, - ["state", "project", "assignees", "labels"] + ["state", "project", "assignees", "labels", "created_by", "updated_by"] ); // Check if the issue is already linked to a thread @@ -399,7 +399,7 @@ async function createIntakeIssueFromViewSubmission( }, }); - const linkBack = createSlackIntakeLinkback(workspaceConnection.workspace_slug, issue, userMap, false); + const linkBack = createSlackIntakeLinkback(workspaceConnection.workspace_slug, issue, userMap, false, member); if (metadata.entityPayload.type === ENTITIES.SHORTCUT_PROJECT_SELECTION) { const payload = metadata.entityPayload as ShortcutActionPayload; @@ -494,15 +494,20 @@ async function createWorkItemFromViewSubmission( workspaceConnection.workspace_slug, projectId, issueId, - ["state", "project", "assignees", "labels"] + ["state", "project", "assignees", "labels", "created_by", "updated_by"] ); const userMap = getUserMapFromSlackWorkspaceConnection(workspaceConnection); + const enhancedUserMap = await enhanceUserMapWithSlackLookup({ + planeUsers: issueWithFields.assignees, + currentUserMap: userMap, + slackService, + }); const linkBack = createSlackLinkback( workspaceConnection.workspace_slug, issueWithFields, - userMap, + enhancedUserMap, parsedData.data.enable_thread_sync ?? false ); @@ -724,7 +729,7 @@ async function processCustomFields(params: { } export const createIssueErrorBlocks = ( - issue: IssueWithExpanded<["state", "project", "assignees", "labels"]>, + issue: IssueWithExpanded<["state", "project", "assignees", "labels", "created_by", "updated_by"]>, channelName: string, threadLink: string, issueLink: string diff --git a/apps/silo/src/apps/slack/worker/plane-webhook-handlers/handle-issue-webhook.ts b/apps/silo/src/apps/slack/worker/plane-webhook-handlers/handle-issue-webhook.ts index 318f489d93..7a4f9513bc 100644 --- a/apps/silo/src/apps/slack/worker/plane-webhook-handlers/handle-issue-webhook.ts +++ b/apps/silo/src/apps/slack/worker/plane-webhook-handlers/handle-issue-webhook.ts @@ -6,7 +6,6 @@ import { getUserMapFromSlackWorkspaceConnection } from "../../helpers/user"; import { ActivityForSlack, PlaneActivityWithTimestamp } from "../../types/types"; import { createActivityLinkback } from "../../views/activity"; - export const handleIssueWebhook = async (payload: PlaneWebhookPayload) => { const activities = await getActivities(payload); // Ideally we won't hit this case, but just in case @@ -26,6 +25,7 @@ export const handleIssueWebhook = async (payload: PlaneWebhookPayload) => { const { slackService, entityConnection, workspaceConnection } = details; + const userMap = getUserMapFromSlackWorkspaceConnection(workspaceConnection); const activityBlocks = createActivityLinkback({ @@ -33,7 +33,7 @@ export const handleIssueWebhook = async (payload: PlaneWebhookPayload) => { workspaceSlug: entityConnection.workspace_slug, projectId: entityConnection.project_id!, issueId: payload.id, - userMap, + userMap: userMap, }); const entityData = entityConnection.entity_data as TSlackIssueEntityData; @@ -90,7 +90,7 @@ export const getActivities = async (payload: PlaneWebhookPayload): Promise { const added: string[] = []; const removed: string[] = []; + const addedIdentifiers: string[] = []; + const removedIdentifiers: string[] = []; for (const activity of activities) { if (activity.old_value === null && activity.new_value !== null) { added.push(activity.new_value); + if (activity.new_identifier) { + addedIdentifiers.push(activity.new_identifier); + } } else if (activity.old_value !== null && activity.new_value === null) { removed.push(activity.old_value); + if (activity.old_identifier) { + removedIdentifiers.push(activity.old_identifier); + } } } - return { added, removed }; + return { added, removed, addedIdentifiers, removedIdentifiers }; }; diff --git a/apps/silo/src/apps/slack/worker/plane-webhook-handlers/handle-project-updates.ts b/apps/silo/src/apps/slack/worker/plane-webhook-handlers/handle-project-updates.ts index c847c88402..dce76471df 100644 --- a/apps/silo/src/apps/slack/worker/plane-webhook-handlers/handle-project-updates.ts +++ b/apps/silo/src/apps/slack/worker/plane-webhook-handlers/handle-project-updates.ts @@ -3,7 +3,7 @@ import { PlaneWebhookPayload } from "@plane/sdk"; import { logger } from "@/logger"; import { getAPIClient } from "@/services/client"; import { getConnectionDetails } from "../../helpers/connection-details"; -import { getUserMapFromSlackWorkspaceConnection } from "../../helpers/user"; +import { enhanceUserMapWithSlackLookup, getUserMapFromSlackWorkspaceConnection } from "../../helpers/user"; import { createSlackLinkback } from "../../views/issue-linkback"; const apiClient = getAPIClient(); @@ -42,12 +42,17 @@ export const handleProjectUpdateWebhook = async (payload: PlaneWebhookPayload) = workspaceConnection.workspace_slug, payload.project, payload.id, - ["state", "project", "assignees", "labels"] + ["state", "project", "assignees", "labels", "created_by", "updated_by"] ); const userMap = getUserMapFromSlackWorkspaceConnection(workspaceConnection); + const enhancedUserMap = await enhanceUserMapWithSlackLookup({ + planeUsers: issue.assignees, + currentUserMap: userMap, + slackService, + }); - const linkback = createSlackLinkback(workspaceConnection.workspace_slug, issue, userMap, false); + const linkback = createSlackLinkback(workspaceConnection.workspace_slug, issue, enhancedUserMap, false); await Promise.all( entityConnections.map(async (entityConnection) => { diff --git a/apps/silo/src/helpers/utils.ts b/apps/silo/src/helpers/utils.ts index 41e9828c3d..bf8e8055f1 100644 --- a/apps/silo/src/helpers/utils.ts +++ b/apps/silo/src/helpers/utils.ts @@ -1,9 +1,11 @@ import axios from "axios"; import { parse, HTMLElement } from "node-html-parser"; +import { validate as uuidValidate } from "uuid"; import { Client as PlaneClient } from "@plane/sdk"; import { env } from "@/env"; import { logger } from "@/logger"; import { getValidCredentials } from "./credential"; + export const removeSpanAroundImg = (htmlContent: string): string => { // Parse the HTML content const root = parse(htmlContent); @@ -101,6 +103,10 @@ export const createPlaneClient = async (workspaceId: string, userId: string, sou } }; +export const titleCase = (word: string) => word.split(" ").map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(" "); + +export const isUUID = (id: string | null) => id && uuidValidate(id); + export const invertStringMap = (map: Map) => { const invertedMap = new Map(); map.forEach((value, key) => { diff --git a/packages/etl/src/slack/services/api.service.ts b/packages/etl/src/slack/services/api.service.ts index 8ae5960146..58d6ac9e19 100644 --- a/packages/etl/src/slack/services/api.service.ts +++ b/packages/etl/src/slack/services/api.service.ts @@ -3,6 +3,7 @@ import { SlackConversationHistoryResponse, SlackMessageResponse, SlackTokenRefreshResponse, + SlackUserLookupByEmailResponse, SlackUserResponse, UnfurlMap, } from "../types"; @@ -79,7 +80,7 @@ export class SlackService { try { const isBotInChannel = await this.ensureBotInChannel(channelId); if (!isBotInChannel) { - throw new Error("Could not add bot to channel"); + console.error("Could not add bot to channel", { channelId }); } const payload: any = { @@ -161,7 +162,7 @@ export class SlackService { const isBotInChannel = await this.ensureBotInChannel(channelId); if (!isBotInChannel) { - throw new Error("Could not add bot to channel"); + console.error("Could not add bot to channel", { channelId }); } // Use the user token to authenticate the request @@ -211,7 +212,7 @@ export class SlackService { const isBotInChannel = await this.ensureBotInChannel(channelId); if (!isBotInChannel) { // If the bot is not in the channel, log it, but make the request anyway - console.log("Could not add bot to channel"); + console.error("Could not add bot to channel", { channelId }); } const response = await this.client.post("chat.postMessage", payload); @@ -243,7 +244,7 @@ export class SlackService { // Check if bot is in channel const isBotInChannel = await this.ensureBotInChannel(channelId); if (!isBotInChannel) { - throw new Error("Could not add bot to channel"); + console.error("Could not add bot to channel", { channelId }); } const response = await this.client.post("chat.postMessage", { @@ -318,6 +319,19 @@ export class SlackService { } } + async userLookupByEmail(email: string): Promise { + try { + const response = await this.client.get("users.lookupByEmail", { + params: { email }, + }); + + return response.data; + } catch (error) { + console.error(error); + throw error; + } + } + async getChannels() { try { const allChannels = []; diff --git a/packages/etl/src/slack/types/auth.ts b/packages/etl/src/slack/types/auth.ts index bf31a1910b..98cbbc13b4 100644 --- a/packages/etl/src/slack/types/auth.ts +++ b/packages/etl/src/slack/types/auth.ts @@ -159,6 +159,14 @@ export type TSlackConnectionData = { name: string; }; +export type SlackUserLookupByEmailResponse = { + ok: true; + user: SlackUser; +} | { + ok: false; + error: string; +}; + export type TAppConnection = { id: string; workspaceId: string; diff --git a/packages/sdk/src/types/types.ts b/packages/sdk/src/types/types.ts index d506c21352..c0810599f6 100644 --- a/packages/sdk/src/types/types.ts +++ b/packages/sdk/src/types/types.ts @@ -88,9 +88,11 @@ type IIsssue = { export type ExpandableFields = { state: ExState; project: ExProject; - assignees: ExUser[]; + assignees: PlaneUser[]; labels: ExIssueLabel[]; type: ExIssueType; + created_by: PlaneUser | undefined; + updated_by: PlaneUser | undefined; } // Create a type that can handle both expanded and unexpanded fields export type IssueWithExpanded> = Omit &