[SILO-482] fix: slack integration post release checklist (#4032)

* fix: silenced error for couldn't add bot to channel

* fix: added expanded field for created_by and updated_by

* fix: added expansion of created_by and updated_by for issues

* fix: updated views to accomodate changes for created and updated fields

* fix: removed reaction helpers and updated blocks

* fix: removed options for enabling mentions

* fix: build fixes

* fix: added support for user lookup even if the user has not connected account

* fix: modified issue block design for bolds

* fix: resolved review comments
This commit is contained in:
Henit Chobisa
2025-09-02 00:23:07 +05:30
committed by GitHub
parent fd08c560f9
commit 1a71c461d6
21 changed files with 391 additions and 149 deletions
+12 -22
View File
@@ -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<ExIssue | ExIssueComment>;
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,
+158 -50
View File
@@ -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<string, string>;
activity: ActivityForSlack;
fieldText: string; // Pre-formatted field name
};
type ActivityFormatter = (context: ActivityFormatterContext) => string;
// Define custom formatters for specific fields
const ACTIVITY_FORMATTERS: Record<string, ActivityFormatter> = {
/**
* 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 <https://app.plane.so/workspace/project/issues/ABC-123|ABC-123>"
* // 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<string, string>,
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<ExIssue | ExIssueComment>) =>
payload.activity.field &&
!payload.activity.field.includes("_id") &&
!isUUID(payload.activity.old_value) &&
!isUUID(payload.activity.new_value);
+23 -19
View File
@@ -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<ExIntakeIssue | IssueWithExpanded<any>, "created_by" | "updated_by">;
issueCtx: {
createdBy?: {
id: string;
display_name: string;
};
updatedBy?: {
id: string;
display_name: string;
};
};
planeToSlackUserMap: Map<string, string>;
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;
@@ -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",
];
+17 -11
View File
@@ -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;
}
};
+2 -2
View File
@@ -223,7 +223,7 @@ async function mapFieldToSlackBlock(
field: FormField,
projectId: string,
metadata?: SlackPrivateMetadata<typeof ENTITIES.SHORTCUT_PROJECT_SELECTION>,
workItem?: Partial<IssueWithExpanded<["state", "project", "assignees", "labels"]>>
workItem?: Partial<IssueWithExpanded<["state", "project", "assignees", "labels", "created_by", "updated_by"]>>
) {
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<typeof ENTITIES.SHORTCUT_PROJECT_SELECTION>,
workItem?: Partial<IssueWithExpanded<["state", "project", "assignees", "labels"]>>
workItem?: Partial<IssueWithExpanded<["state", "project", "assignees", "labels", "created_by", "updated_by"]>>
) {
const { workspaceSlug, projectId, issueTypeId, accessToken } = params;
+51 -2
View File
@@ -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<string, string>, workspaceSlug: string, userId: string, displayName?: string) => {
if (planeToSlackUserMap.has(userId)) {
export const getUserMarkdown = (planeToSlackUserMap: Map<string, string>, 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<string, string>, // slack -> plane mapping
slackService: SlackService,
}): Promise<Map<string, string>> => {
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;
};
+5 -1
View File
@@ -107,7 +107,7 @@ export type TSlackWorkItemOrIntakeModalParams = {
showThreadSync?: boolean;
// Work item to update
workItem?: Partial<IssueWithExpanded<["state", "project", "assignees", "labels"]>>
workItem?: Partial<IssueWithExpanded<["state", "project", "assignees", "labels", "created_by", "updated_by"]>>
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;
}
);
+5 -4
View File
@@ -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,
@@ -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<string, string>,
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: {
@@ -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<string, string>,
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: {
@@ -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<string, string>,
privateMetadata: any = {}
@@ -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,
};
@@ -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,
@@ -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
@@ -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<Activ
const actorDisplayName = fieldActivities[0].actor.display_name;
if (isArrayField) {
const { added, removed } = getArrayActivity(fieldActivities);
const { added, removed, addedIdentifiers, removedIdentifiers } = getArrayActivity(fieldActivities);
latestActivities.set(field, {
isArrayField: true,
field,
@@ -98,6 +98,8 @@ export const getActivities = async (payload: PlaneWebhookPayload): Promise<Activ
actorDisplayName,
added,
removed,
addedIdentifiers,
removedIdentifiers,
timestamp: fieldActivities[fieldActivities.length - 1].timestamp,
});
} else {
@@ -110,6 +112,8 @@ export const getActivities = async (payload: PlaneWebhookPayload): Promise<Activ
actorDisplayName,
newValue: latestActivity.new_value || "",
oldValue: latestActivity.old_value || "",
newIdentifier: latestActivity.new_identifier || "",
oldIdentifier: latestActivity.old_identifier || "",
timestamp: latestActivity.timestamp,
});
}
@@ -128,14 +132,22 @@ export const getArrayActivity = (activities: PlaneActivityWithTimestamp[]) => {
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 };
};
@@ -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) => {
+6
View File
@@ -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<string, string>) => {
const invertedMap = new Map<string, string>();
map.forEach((value, key) => {
+18 -4
View File
@@ -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<SlackUserLookupByEmailResponse> {
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 = [];
+8
View File
@@ -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;
+3 -1
View File
@@ -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<T extends Array<keyof ExpandableFields>> = Omit<ExIssue, T[number]> &