[SILO-178] feat: slack issue linking with threads with Plane updates synced with slack threads

This commit is contained in:
Henit Chobisa
2025-04-29 16:19:43 +05:30
committed by GitHub
parent 8fa0566e06
commit 7fae98a2f0
27 changed files with 1345 additions and 361 deletions

View File

@@ -9,9 +9,15 @@ from plane.api.views import (
WorkspaceIssueAPIEndpoint,
IssueAttachmentEndpoint,
IssueAttachmentServerEndpoint,
IssueSearchEndpoint,
)
urlpatterns = [
path(
"workspaces/<str:slug>/issues/search/",
IssueSearchEndpoint.as_view(),
name="issue-search",
),
path(
"workspaces/<str:slug>/issues/<str:project__identifier>-<str:issue__identifier>/",
WorkspaceIssueAPIEndpoint.as_view(),

View File

@@ -11,6 +11,7 @@ from .issue import (
IssueActivityAPIEndpoint,
IssueAttachmentEndpoint,
IssueAttachmentServerEndpoint,
IssueSearchEndpoint,
)
from .cycle import (

View File

@@ -1,5 +1,6 @@
# Python imports
import json
import re
import uuid
# Django imports
@@ -65,7 +66,6 @@ from plane.payment.flags.flag import FeatureFlag
from plane.ee.utils.workflow import WorkflowStateManager
class WorkspaceIssueAPIEndpoint(BaseAPIView):
"""
This viewset provides `retrieveByIssueId` on workspace level
@@ -289,8 +289,7 @@ class IssueAPIEndpoint(BaseAPIView):
project_id=project_id, slug=slug
)
if workflow_state_manager.validate_issue_creation(
state_id=request.data.get("state_id"),
user_id=request.user.id,
state_id=request.data.get("state_id"), user_id=request.user.id
):
return Response(
{"error": "You cannot create a work item in this state"},
@@ -1410,3 +1409,54 @@ class IssueAttachmentServerEndpoint(BaseAPIView):
get_asset_object_metadata.delay(str(issue_attachment.id))
issue_attachment.save()
return Response(status=status.HTTP_204_NO_CONTENT)
class IssueSearchEndpoint(BaseAPIView):
"""Endpoint to search across multiple fields in the issues"""
def get(self, request, slug):
query = request.query_params.get("search", False)
limit = request.query_params.get("limit", 10)
workspace_search = request.query_params.get("workspace_search", "false")
project_id = request.query_params.get("project_id", False)
if not query:
return Response({"issues": []}, status=status.HTTP_200_OK)
# Build search query
fields = ["name", "sequence_id", "project__identifier"]
q = Q()
for field in fields:
if field == "sequence_id":
# Match whole integers only (exclude decimal numbers)
sequences = re.findall(r"\b\d+\b", query)
for sequence_id in sequences:
q |= Q(**{"sequence_id": sequence_id})
else:
q |= Q(**{f"{field}__icontains": query})
# Filter issues
issues = Issue.issue_objects.filter(
q,
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
project__archived_at__isnull=True,
workspace__slug=slug,
)
# Apply project filter if not searching across workspace
if workspace_search == "false" and project_id:
issues = issues.filter(project_id=project_id)
# Get results
issue_results = issues.distinct().values(
"name",
"id",
"sequence_id",
"project__identifier",
"project_id",
"workspace__slug",
"type_id",
)[: int(limit)]
return Response({"issues": issue_results}, status=status.HTTP_200_OK)

View File

@@ -156,6 +156,7 @@ export type TViewSubmissionPayload = {
team: ISlackTeam;
user: ISlackUser;
api_app_id: string;
callback_id: string;
token: string;
trigger_id: string;
view: ISlackView;

View File

@@ -6,6 +6,9 @@ import {
ExcludedProps,
ExIssue,
ExIssueAttachment,
IssueSearchResponse,
ExpandableFields,
IssueWithExpanded,
Optional,
Paginated,
} from "@/types/types";
@@ -27,6 +30,37 @@ export class IssueService extends APIService {
});
}
async getIssueByIdentifierWithFields(
workspaceSlug: string,
projectIdentifier: string,
issueSequence: number,
expand: (keyof ExpandableFields)[]
): Promise<IssueWithExpanded<typeof expand>> {
return this.get(`/api/v1/workspaces/${workspaceSlug}/issues/${projectIdentifier}-${issueSequence.toString()}/?expand=${expand.join(",")}`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async searchIssues(
workspaceSlug: string,
query: string,
projectId?: string
): Promise<{ issues: IssueSearchResponse[] }> {
return this.get(`/api/v1/workspaces/${workspaceSlug}/issues/search/`, {
params: {
search: query,
project_id: projectId,
workspace_search: !projectId,
},
})
.then((response) => response.data)
.catch((error) => {
throw error?.response?.data;
});
}
async list(slug: string, projectId: string): Promise<Paginated<ExIssue>> {
return this.get(`/api/v1/workspaces/${slug}/projects/${projectId}/issues/`)
.then((response) => response.data)
@@ -202,6 +236,14 @@ export class IssueService extends APIService {
});
}
async getIssueWithFields(workspaceSlug: string, projectId: string, issueId: string, expand: (keyof ExpandableFields)[]): Promise<IssueWithExpanded<typeof expand>> {
return this.get(`/api/v1/workspaces/${workspaceSlug}/projects/${projectId}/issues/${issueId}/?expand=${expand.join(",")}`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async getIssueAttachments(workspaceSlug: string, projectId: string, issueId: string): Promise<ExIssueAttachment[]> {
return this.get(
`/api/v1/workspaces/${workspaceSlug}/projects/${projectId}/issues/${issueId}/issue-attachments/server/`

View File

@@ -1,10 +1,3 @@
import {
// issue types
ExIssueType,
ExIssueProperty,
ExIssuePropertyOption,
ExIssuePropertyValue,
} from "./issue-types";
// service types
export type ClientOptions = {
@@ -91,6 +84,17 @@ type IIsssue = {
type_id: string | undefined;
};
export type ExpandableFields = {
state: ExState;
project: ExProject;
assignees: ExUser[];
labels: ExIssueLabel[];
}
// Create a type that can handle both expanded and unexpanded fields
export type IssueWithExpanded<T extends Array<keyof ExpandableFields>> = Omit<ExIssue, T[number]> &
Pick<ExpandableFields, T[number]>;
export type TStateGroups = "backlog" | "unstarted" | "started" | "completed" | "cancelled";
export interface IState {
@@ -341,6 +345,16 @@ export interface AttachmentResponse {
asset_url: string;
}
export interface IssueSearchResponse {
name: string;
id: string;
sequence_id: number;
project__identifier: string;
project_id: string;
workspace__slug: string;
type_id: string | null;
}
export interface ExAsset {
id: string;
attributes: {

View File

@@ -3,7 +3,8 @@ import { ExIssue, ExIssueComment, PlaneUser } from "./types";
// Common types
type UUID = string;
interface Activity {
export interface PlaneActivity {
timestamp: string;
field: string;
new_value: string | null;
old_value: string | null;
@@ -18,7 +19,7 @@ export interface WebhookPayloadBase {
action: string;
webhook_id: UUID;
workspace_id: UUID;
activity: Activity;
activity: PlaneActivity;
}
export interface WebhookIssuePayload extends WebhookPayloadBase {
@@ -71,7 +72,7 @@ export interface PlaneWebhookPayloadBase<data = any> {
action: string;
webhook_id: UUID;
workspace_id: UUID;
activity: Activity;
activity: PlaneActivity;
}
// Union type for all possible webhook payloads

View File

@@ -7,7 +7,7 @@ import {
TSlackCommandPayload,
TSlackPayload
} from "@plane/etl/slack";
import { PlaneWebhookData } from "@plane/sdk";
import { ExIssue, PlaneWebhookData, PlaneWebhookPayloadBase } from "@plane/sdk";
import { env } from "@/env";
import { responseHandler } from "@/helpers/response-handler";
import { Controller, EnsureEnabled, Get, Post, useValidateUserAuthentication } from "@/lib";
@@ -19,6 +19,7 @@ import { getConnectionDetails } from "../helpers/connection-details";
import { ACTIONS } from "../helpers/constants";
import { parseIssueFormData } from "../helpers/parse-issue-form";
import { convertToSlackOptions } from "../helpers/slack-options";
import { Store } from "@/worker/base";
const apiClient = getAPIClient();
@EnsureEnabled(E_INTEGRATION_KEYS.SLACK)
@@ -380,6 +381,31 @@ export default class SlackController {
});
}
if (payload.type === "block_suggestion" && payload.action_id && payload.action_id === ACTIONS.LINK_WORK_ITEM) {
const text = payload.value;
// If the action is link_work_item, parse the view to be of type
// LinkIssueModalView and pass it to the slack worker
const details = await getConnectionDetails(payload.team.id);
if (!details) { logger.info(`[SLACK] No connection details found for team ${payload.team.id}`); return }
const { workspaceConnection, planeClient } = details;
const workItems = await planeClient.issue.searchIssues(workspaceConnection.workspace_slug, text);
const filteredWorkItems = workItems.issues
.map((workItem) => {
return {
id: `${workItem.workspace__slug}:${workItem.project_id}:${workItem.id}`,
name: `[${workItem.project__identifier}-${workItem.sequence_id}] ${workItem.name}`,
}
})
.sort((a, b) => a.name.localeCompare(b.name));
const workItemOptions = convertToSlackOptions(filteredWorkItems);
return res.status(200).json({
options: workItemOptions,
});
}
return res.status(200).json({});
} catch (error) {
return responseHandler(res, 500, error);
@@ -402,9 +428,70 @@ export default class SlackController {
);
}
if (payload.event === "issue" && !payload.activity.field.includes("_id")) {
const payload = req.body as PlaneWebhookPayloadBase<ExIssue>;
const id = payload.data.id;
const workspace = payload.data.workspace;
const project = payload.data.project;
const issue = payload.data.issue;
const [entityConnection] = await apiClient.workspaceEntityConnection.listWorkspaceEntityConnections({
workspace_id: workspace,
project_id: project,
issue_id: id,
entity_type: E_INTEGRATION_KEYS.SLACK,
});
if (!entityConnection) {
return res.sendStatus(200);
}
logger.info(`[SLACK] Entity connection found for issue ${id} in workspace ${workspace} and project ${project}`);
// Register activity key for the particular issue
await this.collectActivityForStacking(payload);
// Register store task for stacking the issue
await integrationTaskManager.registerStoreTask(
{
route: "plane-slack-webhook",
jobId: payload.event,
type: "issue",
},
{
id,
event: payload.event,
workspace,
project,
issue,
},
Number(env.DEDUP_INTERVAL)
);
}
return res.sendStatus(200);
} catch (error) {
return responseHandler(res, 500, error);
}
}
/**
* Collects activity for stacking the issue
* @param payload - The payload of the issue
*/
async collectActivityForStacking(payload: PlaneWebhookPayloadBase<ExIssue>) {
const store = Store.getInstance()
// Create a key for the activity field
const key = `slack:issue:${payload.data.id}`;
const ttl = 60 // 1 minute
const activity = {
...payload.activity,
timestamp: payload.data.updated_at ?? payload.data.created_at ?? new Date().toISOString(),
}
// Set the activity field, as we gave false, it's gonna update the field
await store.setList(key, JSON.stringify(activity), ttl, false);
}
}

View File

@@ -1,15 +1,23 @@
import { E_INTEGRATION_KEYS } from "@plane/etl/core";
import { createSlackService } from "@plane/etl/slack";
import { Client as PlaneClient } from "@plane/sdk";
import { createSlackService, SlackService } from "@plane/etl/slack";
import { Client as PlaneClient, PlaneWebhookPayload } from "@plane/sdk";
import { env } from "@/env";
import { logger } from "@/logger";
import { getAPIClient } from "@/services/client";
import { slackAuth } from "../auth/auth";
import { getRefreshCredentialHandler } from "./update-credentials";
import { TWorkspaceConnection, TWorkspaceCredential } from "@plane/types";
export type SlackConnectionDetails = {
workspaceConnection: TWorkspaceConnection;
credentials: TWorkspaceCredential;
slackService: SlackService;
planeClient: PlaneClient;
}
export const getConnectionDetails = async (teamId: string) => {
const apiClient = getAPIClient();
const apiClient = getAPIClient();
export const getConnectionDetails = async (teamId: string): Promise<SlackConnectionDetails | null> => {
const [workspaceConnection] = await apiClient.workspaceConnection.listWorkspaceConnections({
connection_id: teamId,
connection_type: E_INTEGRATION_KEYS.SLACK,
@@ -64,3 +72,51 @@ export const getConnectionDetails = async (teamId: string) => {
planeClient,
};
};
export const getConnectionDetailsForIssue = async (payload: PlaneWebhookPayload) => {
const [entityConnection] = await apiClient.workspaceEntityConnection.listWorkspaceEntityConnections({
workspace_id: payload.workspace,
project_id: payload.project,
issue_id: payload.id,
});
if (!entityConnection) {
return;
}
const workspaceConnection = await apiClient.workspaceConnection.getWorkspaceConnection(
entityConnection.workspace_connection_id
);
if (!workspaceConnection) {
return;
}
const credentials = await apiClient.workspaceCredential.getWorkspaceCredential(workspaceConnection.credential_id);
if (!credentials) {
return;
}
if (!credentials.source_access_token || !credentials.source_refresh_token || !credentials.target_access_token) {
return;
}
const refreshHandler = getRefreshCredentialHandler(
workspaceConnection.workspace_id,
credentials.user_id,
credentials.target_access_token
);
const slackService = createSlackService(
credentials.source_access_token,
credentials.source_refresh_token,
slackAuth,
refreshHandler
);
return {
entityConnection,
slackService,
};
};

View File

@@ -13,8 +13,11 @@ export const ACTIONS = {
LINKBACK_COMMENT_SUBMIT: "comment_submit",
LINKBACK_ADD_WEB_LINK: "add_web_link",
LINKBACK_OVERFLOW_ACTIONS: "linkback_overflow_actions",
ENABLE_THREAD_SYNC: "enable_thread_sync",
ASSIGN_TO_ME: "assign_to_me",
LINK_WORK_ITEM: "link_work_item",
};
export type EntityType = keyof typeof ENTITIES;
@@ -26,6 +29,8 @@ export const ENTITIES = {
ISSUE_SUBMISSION: "issue_submission",
ISSUE_COMMENT_SUBMISSION: "issue_comment_submission",
ISSUE_WEBLINK_SUBMISSION: "issue_weblink_submission",
LINK_WORK_ITEM: "link_work_item",
DISCONNECT_WORK_ITEM: "disconnect_work_item",
} as const;
export const PLANE_PRIORITIES = [

View File

@@ -1,4 +1,4 @@
import { ParsedIssueData } from "../types/types";
import { ParsedIssueData, ParsedLinkWorkItemData } from "../types/types";
export const parseIssueFormData = (values: any): ParsedIssueData => {
const parsed: ParsedIssueData = {
@@ -48,3 +48,28 @@ export const parseIssueFormData = (values: any): ParsedIssueData => {
return parsed;
};
export const parseLinkWorkItemFormData = (values: any): ParsedLinkWorkItemData | undefined => {
let parsed: ParsedLinkWorkItemData | undefined = undefined
Object.entries(values).forEach(([_, blockData]: [string, any]) => {
if (blockData.link_work_item?.type === "external_select") {
const identifer = blockData?.link_work_item?.selected_option?.value;
if (!identifer) {
return;
}
const parts = identifer.split(":");
if (parts.length === 3) {
parsed = {
workspaceSlug: parts[0],
projectId: parts[1],
issueId: parts[2],
};
}
}
});
return parsed;
};

View File

@@ -1,12 +1,8 @@
import {
ISlackChannel,
ISlackMessage,
ISlackUser,
TMessageActionPayload,
TSlackCommandPayload,
TSlackPayload,
ISlackChannel, ISlackUser, TSlackPayload
} from "@plane/etl/slack";
import { ENTITIES } from "../helpers/constants";
import { PlaneActivity } from "@plane/sdk";
export interface ParsedIssueData {
project: string;
@@ -18,6 +14,12 @@ export interface ParsedIssueData {
enableThreadSync?: boolean;
}
export interface ParsedLinkWorkItemData {
projectId: string;
issueId: string;
workspaceSlug: string;
}
export interface MetadataPayloadShort {
type: string;
channel: ISlackChannel;
@@ -43,21 +45,59 @@ export type ShortcutActionPayload = {
};
response_url?: string;
};
// Define the payload mapping first
type EntityPayloadMapping = {
[ENTITIES.ISSUE_SUBMISSION]: TSlackPayload;
[ENTITIES.ISSUE_WEBLINK_SUBMISSION]: MetadataPayloadShort;
[ENTITIES.ISSUE_COMMENT_SUBMISSION]: MetadataPayloadShort;
[ENTITIES.SHORTCUT_PROJECT_SELECTION]: ShortcutActionPayload;
[ENTITIES.COMMAND_PROJECT_SELECTION]: Omit<ShortcutActionPayload, "message">;
[ENTITIES.LINK_WORK_ITEM]: ShortcutActionPayload;
[ENTITIES.DISCONNECT_WORK_ITEM]: ShortcutActionPayload;
// Add more mappings as needed
};
export type SlackPrivateMetadata =
| {
entityType: typeof ENTITIES.SHORTCUT_PROJECT_SELECTION;
entityPayload: ShortcutActionPayload;
}
| {
entityType: typeof ENTITIES.COMMAND_PROJECT_SELECTION;
entityPayload: TSlackCommandPayload;
}
| {
entityType: typeof ENTITIES.ISSUE_SUBMISSION;
entityPayload: TSlackPayload;
}
| {
entityType: typeof ENTITIES.ISSUE_WEBLINK_SUBMISSION | typeof ENTITIES.ISSUE_COMMENT_SUBMISSION;
entityPayload: MetadataPayloadShort;
};
// Create the indexed type
export type SlackPrivateMetadata<T extends keyof EntityPayloadMapping = keyof EntityPayloadMapping> = {
entityType: T;
entityPayload: EntityPayloadMapping[T];
};
// export type SlackPrivateMetadata<T extends string> =
// | {
// entityType: typeof ENTITIES.ISSUE_SUBMISSION;
// entityPayload: TSlackPayload;
// }
// | {
// entityType: typeof ENTITIES.ISSUE_WEBLINK_SUBMISSION | typeof ENTITIES.ISSUE_COMMENT_SUBMISSION;
// entityPayload: MetadataPayloadShort;
// }
// | {
// entityType: typeof ENTITIES.SHORTCUT_PROJECT_SELECTION | typeof ENTITIES.LINK_WORK_ITEM | typeof ENTITIES.DISCONNECT_WORK_ITEM;
// entityPayload: ShortcutActionPayload;
// }
export enum E_MESSAGE_ACTION_TYPES {
LINK_WORK_ITEM = "link_work_item",
CREATE_NEW_WORK_ITEM = "create_new_issue",
DISCONNECT_WORK_ITEM = "disconnect_work_item",
ISSUE_WEBLINK_SUBMISSION = "issue_weblink_submission",
ISSUE_COMMENT_SUBMISSION = "issue_comment_submission",
}
export type PlaneActivityWithTimestamp = {
timestamp: string;
} & PlaneActivity;
export type ActivityForSlack = {
field: string;
actor: string;
} & ({
isArrayField: true;
removed: string[];
added: string[];
} | {
isArrayField: false;
newValue: string;
})

View File

@@ -1,10 +1,11 @@
import { ACTIONS, ENTITIES } from "../helpers/constants";
import { MetadataPayloadShort } from "../types/types";
import { E_MESSAGE_ACTION_TYPES, MetadataPayloadShort } from "../types/types";
export const createCommentModal = (payload: MetadataPayloadShort) => {
return {
type: "modal",
private_metadata: JSON.stringify({ entityType: ENTITIES.ISSUE_COMMENT_SUBMISSION, entityPayload: payload }),
callback_id: E_MESSAGE_ACTION_TYPES.ISSUE_COMMENT_SUBMISSION,
title: {
type: "plain_text",
text: "Create Comment",

View File

@@ -1,9 +1,10 @@
import { ENTITIES } from "../helpers/constants";
import { MetadataPayloadShort } from "../types/types";
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",

View File

@@ -1,4 +1,4 @@
import { InputBlock, StaticSelect, PlainTextInput, MultiExternalSelect, Checkboxes } from "@slack/types";
import { InputBlock, StaticSelect, PlainTextInput, MultiExternalSelect, Checkboxes, ExternalSelect } from "@slack/types";
export interface StaticSelectInputBlock extends InputBlock {
element: StaticSelect;
@@ -15,3 +15,7 @@ export interface MultiExternalSelectInputBlock extends InputBlock {
export interface CheckboxInputBlock extends InputBlock {
element: Checkboxes;
}
export interface ExternalSelectInputBlock extends InputBlock {
element: ExternalSelect;
}

View File

@@ -1,27 +1,25 @@
import { env } from "@/env";
import { ExIssue, ExProject, ExState, PlaneUser } from "@plane/sdk";
import { ExState, IssueWithExpanded } from "@plane/sdk";
import { formatTimestampToNaturalLanguage } from "../helpers/format-date";
import { ACTIONS, PLANE_PRIORITIES } from "../helpers/constants";
import { convertUnicodeToSlackEmoji } from "../helpers/emoji-converter";
export const createSlackLinkback = (
workspaceSlug: string,
project: ExProject,
members: PlaneUser[],
state: ExState[],
issue: ExIssue,
issue: IssueWithExpanded<["state", "project", "assignees", "labels"]>,
states: ExState[],
isSynced: boolean,
showLogo = false
showLogo = false,
hideOverflowMenu = false,
asHeader = false
) => {
const targetState = state.find((s) => s.id === issue.state);
const stateList = state.map((s) => ({
const stateList = states.map((s) => ({
text: {
type: "plain_text",
text: s.name,
emoji: true,
},
value: `${issue.project}.${issue.id}.${s.id}`,
value: `${issue.project.id}.${issue.id}.${s.id}`,
}));
const priorityList = PLANE_PRIORITIES.map((p) => ({
@@ -30,7 +28,7 @@ export const createSlackLinkback = (
text: p.name,
emoji: true,
},
value: `${issue.project}.${issue.id}.${p.id}`,
value: `${issue.project.id}.${issue.id}.${p.id}`,
}));
// Create base blocks array
@@ -57,24 +55,24 @@ export const createSlackLinkback = (
type: "section",
text: {
type: "mrkdwn",
text: `<${env.APP_BASE_URL}/${workspaceSlug}/projects/${issue.project}/issues/${issue.id}| ${project.identifier}-${issue.sequence_id} ${issue.name}>`,
text: asHeader ? `*${issue.project.identifier}-${issue.sequence_id} ${issue.name}*` : `<${env.APP_BASE_URL}/${workspaceSlug}/projects/${issue.project.id}/issues/${issue.id}| ${issue.project.identifier}-${issue.sequence_id} ${issue.name}>`,
},
});
// Create context elements array with only non-empty values
const contextElements: any[] = [];
if (project.name) {
const emoji = project.logo_props && project.logo_props?.in_use === "emoji" && project.logo_props?.emoji && project.logo_props?.emoji?.value ? convertUnicodeToSlackEmoji(project.logo_props?.emoji?.value) : "📋";
if (issue.project.name) {
const emoji = issue.project.logo_props && issue.project.logo_props?.in_use === "emoji" && issue.project.logo_props?.emoji && issue.project.logo_props?.emoji?.value ? convertUnicodeToSlackEmoji(issue.project.logo_props?.emoji?.value) : "📋";
contextElements.push({
type: "mrkdwn",
text: `${emoji} <${env.APP_BASE_URL}/${workspaceSlug}/projects/${issue.project}/issues|${project.name}>`,
text: `${emoji} <${env.APP_BASE_URL}/${workspaceSlug}/projects/${issue.project.id}/issues|${issue.project.name}>`,
});
}
// Add state and priority labels
const stateLabel = targetState ? `*State:* ${targetState.name}` : "*State:* Not set";
const stateLabel = issue.state ? `*State:* ${issue.state.name}` : "*State:* Not set";
const priorityLabel = issue.priority ? `*Priority:* ${titleCaseWord(issue.priority)}` : "*Priority:* Not set";
contextElements.push({
@@ -84,12 +82,11 @@ export const createSlackLinkback = (
if (issue.assignees && issue.assignees.length > 0) {
const uniqueAssignees = Array.from(new Set(issue.assignees));
const firstAssignee = members.find((m) => m.id === uniqueAssignees[0]);
const firstAssignee = issue.assignees.find((m) => m.id === uniqueAssignees[0].id);
contextElements.push({
type: "mrkdwn",
text: `*${uniqueAssignees.length > 1 ? "Assignees:" : "Assignee:"}* ${firstAssignee?.display_name} ${uniqueAssignees.length > 1 ? `and ${uniqueAssignees.length - 1} others` : ""}`,
text: `*${uniqueAssignees.length > 1 ? "Assignees:" : "Assignee:"}* ${firstAssignee?.first_name} ${uniqueAssignees.length > 1 ? `and ${uniqueAssignees.length - 1} others` : ""}`,
});
}
@@ -161,38 +158,63 @@ export const createSlackLinkback = (
}
// Add overflow menu for button actions
const overflowMenu = {
type: "overflow",
action_id: ACTIONS.LINKBACK_OVERFLOW_ACTIONS,
options: [
{
text: {
type: "plain_text",
text: "Add Link",
emoji: true,
if (!hideOverflowMenu) {
const overflowMenu = {
type: "overflow",
action_id: ACTIONS.LINKBACK_OVERFLOW_ACTIONS,
options: [
{
text: {
type: "plain_text",
text: "Add Link",
emoji: true,
},
value: `${issue.project.id}.${issue.id}`,
},
value: `${issue.project}.${issue.id}`,
},
{
text: {
type: "plain_text",
text: "Comment",
emoji: true,
{
text: {
type: "plain_text",
text: "Comment",
emoji: true,
},
value: `${issue.project.id}.${issue.id}`,
},
value: `${issue.project}.${issue.id}`,
},
{
text: {
type: "plain_text",
text: "Assign to me",
emoji: true,
{
text: {
type: "plain_text",
text: "Assign to me",
emoji: true,
},
value: `${issue.project.id}.${issue.id}`,
},
value: `${issue.project}.${issue.id}`,
},
],
};
],
};
actionElements.push(overflowMenu);
actionElements.push(overflowMenu);
}
if (hideOverflowMenu) {
// Give a button for assign to me
actionElements.push({
type: "button",
text: {
type: "plain_text",
text: "Assign to me",
},
value: `${issue.project.id}.${issue.id}`,
});
}
if (asHeader) {
actionElements.push({
type: "button",
text: {
type: "plain_text",
text: "Open in Plane",
},
url: `${env.APP_BASE_URL}/${workspaceSlug}/projects/${issue.project.id}/issues/${issue.id}`,
});
}
// Add actions block if there are any elements
if (actionElements.length > 0) {

View File

@@ -7,9 +7,11 @@ import {
PlainTextInputBlock,
StaticSelectInputBlock,
} from "./custom-blocks";
import { E_MESSAGE_ACTION_TYPES } from "../types/types";
export type IssueModalViewFull = {
type: "modal";
callback_id: string;
private_metadata: string;
title: PlainTextElement;
submit: PlainTextElement;
@@ -44,6 +46,7 @@ export const createIssueModalViewFull = (
showThreadSync: boolean = true
): IssueModalViewFull => ({
type: "modal",
callback_id: E_MESSAGE_ACTION_TYPES.CREATE_NEW_WORK_ITEM,
private_metadata: privateMetadata,
title: {
type: "plain_text",
@@ -177,30 +180,30 @@ export const createIssueModalViewFull = (
},
...(showThreadSync
? [
{
type: "input",
optional: true,
element: {
type: "checkboxes",
options: [
{
text: {
type: "plain_text",
text: "Sync slack comments with plane comments and vice versa",
emoji: true,
},
value: "true",
{
type: "input",
optional: true,
element: {
type: "checkboxes",
options: [
{
text: {
type: "plain_text",
text: "Sync slack comments with plane comments and vice versa",
emoji: true,
},
],
action_id: "enable_thread_sync",
},
label: {
type: "plain_text",
text: "Thread Sync",
emoji: true,
},
value: "true",
},
],
action_id: "enable_thread_sync",
},
]
label: {
type: "plain_text",
text: "Thread Sync",
emoji: true,
},
},
]
: []),
],
});

View File

@@ -0,0 +1,120 @@
import { ACTIONS, ENTITIES } from "../helpers/constants";
import { PlainTextElement } from "@slack/types";
import { E_MESSAGE_ACTION_TYPES } from "../types/types";
import { ExState, IssueWithExpanded } from "@plane/sdk";
import { createSlackLinkback } from "./issue-linkback";
export type LinkIssueModalView = {
type: "modal";
callback_id: string;
private_metadata: string;
title: PlainTextElement;
submit: PlainTextElement;
close: PlainTextElement;
blocks: any;
};
/**
* Creates a modal view for linking existing issues
* @param privateMetadata Metadata to pass through the modal (JSON string)
* @returns A configured modal view
*/
export const createLinkIssueModalView = (
privateMetadata: any = {},
error: string | null = null
): LinkIssueModalView => ({
type: "modal",
callback_id: E_MESSAGE_ACTION_TYPES.LINK_WORK_ITEM,
private_metadata: JSON.stringify({
entityType: ENTITIES.LINK_WORK_ITEM,
entityPayload: privateMetadata,
}),
title: {
type: "plain_text",
text: "Link Work Item",
emoji: true,
},
submit: {
type: "plain_text",
text: "Link",
emoji: true,
},
close: {
type: "plain_text",
text: "Cancel",
emoji: true,
},
blocks: [
...(error ? [{
type: "section",
text: {
type: "mrkdwn",
text: error,
},
}] : []),
{
type: "input",
block_id: "work_item_select",
element: {
type: "external_select",
placeholder: {
type: "plain_text",
text: "Search for work items by title or ID",
emoji: true,
},
min_query_length: 2,
action_id: ACTIONS.LINK_WORK_ITEM,
focus_on_load: true,
},
label: {
type: "plain_text",
text: "Work Items",
emoji: true,
},
},
],
});
/**
* Creates a modal view for already linked issues
* @param workspaceSlug The workspace slug
* @param project The project details
* @param state The issue state
* @param issue The issue that is already linked
* @param assignees Optional array of assignee users
* @returns A configured modal view with issue context
*/
export const alreadyLinkedModalView = (
workspaceSlug: string,
issue: IssueWithExpanded<["state", "project", "assignees", "labels"]>,
states: ExState[],
privateMetadata: any = {}
) => {
const linkbackBlocks = createSlackLinkback(workspaceSlug, issue, states, false, false, true, true);
return {
type: "modal",
callback_id: E_MESSAGE_ACTION_TYPES.DISCONNECT_WORK_ITEM,
private_metadata: JSON.stringify({
entityType: ENTITIES.DISCONNECT_WORK_ITEM,
entityPayload: { ...privateMetadata },
}),
title: {
type: "plain_text",
text: "Linked Work Item",
emoji: true,
},
submit: {
type: "plain_text",
text: "Disconnect",
emoji: true,
},
close: {
type: "plain_text",
text: "Cancel",
emoji: true,
},
blocks: linkbackBlocks.blocks,
};
};

View File

@@ -1,6 +1,6 @@
import { ACTIONS, ENTITIES, EntityTypeValue } from "../helpers/constants";
import { PlainTextOption } from "../helpers/slack-options";
import { ShortcutActionPayload } from "../types/types";
import { E_MESSAGE_ACTION_TYPES, ShortcutActionPayload } from "../types/types";
export const createProjectSelectionModal = (
projects: Array<PlainTextOption>,
@@ -8,6 +8,7 @@ export const createProjectSelectionModal = (
type: EntityTypeValue = ENTITIES.SHORTCUT_PROJECT_SELECTION
) => ({
type: "modal",
callback_id: E_MESSAGE_ACTION_TYPES.CREATE_NEW_WORK_ITEM,
private_metadata: JSON.stringify({
entityType: type,
entityPayload: privateMetadata,

View File

@@ -189,17 +189,13 @@ async function handleSwitchPriorityAction(data: TBlockActionPayload) {
priority: value[2],
});
const issue = await planeClient.issue.getIssue(workspaceConnection.workspace_slug, projectId, issueId);
const project = await planeClient.project.getProject(workspaceConnection.workspace_slug, projectId);
const issue = await planeClient.issue.getIssueWithFields(workspaceConnection.workspace_slug, projectId, issueId, ["state", "project", "assignees", "labels"]);
const states = await planeClient.state.list(workspaceConnection.workspace_slug, projectId);
const members = await planeClient.users.list(workspaceConnection.workspace_slug, projectId);
const updatedLinkback = createSlackLinkback(
workspaceConnection.workspace_slug,
project,
members,
states.results,
issue,
states.results,
isThreadSync
);
@@ -243,12 +239,11 @@ async function handleProjectSelectAction(data: TBlockActionModalPayload) {
const projects = await planeClient.project.list(workspaceConnection.workspace_slug);
const selectedProject = await planeClient.project.getProject(workspaceConnection.workspace_slug, selection.value);
const projectAssets = await fetchPlaneAssets(workspaceConnection.workspace_slug, selection.value, planeClient);
const metadata = JSON.parse(data.view.private_metadata) as SlackPrivateMetadata;
const metadata = JSON.parse(data.view.private_metadata) as SlackPrivateMetadata<typeof ENTITIES.SHORTCUT_PROJECT_SELECTION>;
if (
(metadata && metadata.entityPayload.type === "message_action") ||
metadata.entityPayload.type === "shortcut" ||
metadata.entityPayload.type === "command_project_selection"
metadata.entityType === ENTITIES.SHORTCUT_PROJECT_SELECTION ||
metadata.entityType === ENTITIES.COMMAND_PROJECT_SELECTION
) {
const modal = createIssueModalViewFull(
{
@@ -257,11 +252,11 @@ async function handleProjectSelectAction(data: TBlockActionModalPayload) {
priorityOptions: convertToSlackOptions(PLANE_PRIORITIES),
stateOptions: convertToSlackOptions(projectAssets.states.results),
},
metadata.entityType === ENTITIES.SHORTCUT_PROJECT_SELECTION && metadata.entityPayload.type === "message_action"
metadata.entityType === ENTITIES.SHORTCUT_PROJECT_SELECTION
? metadata.entityPayload.message?.text
: "",
JSON.stringify({ entityType: ENTITIES.ISSUE_SUBMISSION, entityPayload: metadata.entityPayload }),
metadata.entityPayload.type === "command_project_selection" ? false : true
JSON.stringify({ entityType: metadata.entityType, entityPayload: metadata.entityPayload }),
metadata.entityPayload.type !== ENTITIES.COMMAND_PROJECT_SELECTION
);
await slackService.updateModal(data.view.id, modal);
@@ -295,17 +290,13 @@ async function handleLinkbackStateChange(data: TBlockActionPayload) {
state: stateId,
});
const issue = await planeClient.issue.getIssue(workspaceConnection.workspace_slug, projectId, issueId);
const project = await planeClient.project.getProject(workspaceConnection.workspace_slug, projectId);
const issue = await planeClient.issue.getIssueWithFields(workspaceConnection.workspace_slug, projectId, issueId, ["state", "project", "assignees", "labels"]);
const states = await planeClient.state.list(workspaceConnection.workspace_slug, projectId);
const members = await planeClient.users.list(workspaceConnection.workspace_slug, projectId);
const updatedLinkback = createSlackLinkback(
workspaceConnection.workspace_slug,
project,
members,
states.results,
issue,
states.results,
isThreadSync
);

View File

@@ -67,7 +67,7 @@ export const handleMessageEvent = async (data: SlackEventPayload) => {
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;
const issueId = eConnection.entity_slug ?? eConnection.issue_id;
const planeUser = members.find((member) => member.email === userInfo?.user.profile.email);
@@ -103,24 +103,21 @@ 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.getIssueByIdentifier(
const issue = await planeClient.issue.getIssueByIdentifierWithFields(
workspaceConnection.workspace_slug,
resource.projectIdentifier,
Number(resource.issueKey)
Number(resource.issueKey),
["state", "project", "assignees", "labels"]
);
const project = await planeClient.project.getProject(workspaceConnection.workspace_slug, issue.project);
const members = await planeClient.users.list(workspaceConnection.workspace_slug, issue.project);
const states = await planeClient.state.list(workspaceConnection.workspace_slug, issue.project);
const states = await planeClient.state.list(workspaceConnection.workspace_slug, issue.project.id ?? "");
const linkBack = createSlackLinkback(
workspaceConnection.workspace_slug,
project,
members,
states.results,
issue,
states.results,
false,
true
);

View File

@@ -3,8 +3,91 @@ import { convertToSlackOptions } from "@/apps/slack/helpers/slack-options";
import { createProjectSelectionModal } from "@/apps/slack/views";
import { logger } from "@/logger";
import { getConnectionDetails } from "../../helpers/connection-details";
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()
export const handleMessageAction = async (data: TMessageActionPayload) => {
if (!data.callback_id) {
logger.info(`[SLACK] No callback id found for message action ${data.type}`);
return
}
switch (data.callback_id) {
case E_MESSAGE_ACTION_TYPES.LINK_WORK_ITEM:
await handleLinkWorkItem(data);
break;
case E_MESSAGE_ACTION_TYPES.CREATE_NEW_WORK_ITEM:
await handleCreateNewWorkItem(data);
break;
}
};
const handleLinkWorkItem = async (data: TMessageActionPayload) => {
// Get the workspace connection for the associated team
/*
* A work item can only be linked to one thread at a time
1. If connected to existing work item, show the work item details in the modal and disconnect button
2. If no work item is connected, show the modal to link a work item
3. If selected work item is already linked to another thread, show a message to the user that it is
already linked to another thread and to disconnect it first
*/
const details = await getConnectionDetails(data.team.id);
if (!details) {
logger.info(`[SLACK] No connection details found for team ${data.team.id}`);
return;
}
const { slackService, planeClient } = details;
const metadata = {
type: "message_action",
channel: {
id: data.channel.id,
name: data.channel.name,
},
message: {
text: data.message.text,
ts: data.message.ts,
},
}
const workspaceEntityConnections = await apiClient.workspaceEntityConnection.listWorkspaceEntityConnections({
workspace_connection_id: details.workspaceConnection.id,
entity_type: E_INTEGRATION_KEYS.SLACK,
entity_id: data.message.ts,
});
let modal: any | undefined;
if (workspaceEntityConnections.length > 0) {
const issuePromise = planeClient.issue.getIssueWithFields(workspaceEntityConnections[0].workspace_slug, workspaceEntityConnections[0].project_id!, workspaceEntityConnections[0].issue_id!, ["state", "project", "assignees", "labels"]);
const statesPromise = planeClient.state.list(workspaceEntityConnections[0].workspace_slug, workspaceEntityConnections[0].project_id!);
const [issue, states] = await Promise.all([issuePromise, statesPromise]);
modal = alreadyLinkedModalView(workspaceEntityConnections[0].workspace_slug, issue, states.results, metadata);
} else {
modal = createLinkIssueModalView(metadata);
}
try {
const res = await slackService.openModal(data.trigger_id, modal);
if (res && !res.ok) {
logger.error("Something went wrong while opening the modal", res);
}
} catch (error) {
logger.error(error);
}
}
const handleCreateNewWorkItem = async (data: TMessageActionPayload) => {
// Get the workspace connection for the associated team
const details = await getConnectionDetails(data.team.id);
if (!details) {
@@ -18,7 +101,7 @@ export const handleMessageAction = async (data: TMessageActionPayload) => {
const filteredProjects = projects.results.filter((project) => project.is_member === true);
const plainTextOptions = convertToSlackOptions(filteredProjects);
const modal = createProjectSelectionModal(plainTextOptions, {
type: "message_action",
type: ENTITIES.SHORTCUT_PROJECT_SELECTION,
message: {
text: data.message.text,
ts: data.message.ts,
@@ -36,4 +119,4 @@ export const handleMessageAction = async (data: TMessageActionPayload) => {
} catch (error) {
logger.error(error);
}
};
}

View File

@@ -1,243 +1,461 @@
import { E_INTEGRATION_KEYS } from "@plane/etl/core";
import { TViewSubmissionPayload } from "@plane/etl/slack";
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 { getConnectionDetails, SlackConnectionDetails } from "../../helpers/connection-details";
import { ENTITIES } from "../../helpers/constants";
import { parseIssueFormData } from "../../helpers/parse-issue-form";
import { SlackPrivateMetadata } from "../../types/types";
import { parseIssueFormData, parseLinkWorkItemFormData } from "../../helpers/parse-issue-form";
import { E_MESSAGE_ACTION_TYPES, SlackPrivateMetadata } 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();
export const handleViewSubmission = async (data: TViewSubmissionPayload) => {
const details = await getConnectionDetails(data.team.id);
if (!details) {
logger.info(`[SLACK] No connection details found for team ${data.team.id}`);
return;
}
const { workspaceConnection, slackService, planeClient, credentials } = details;
switch (data.view.callback_id) {
case E_MESSAGE_ACTION_TYPES.LINK_WORK_ITEM:
return await handleLinkWorkItemViewSubmission(details, data);
case E_MESSAGE_ACTION_TYPES.DISCONNECT_WORK_ITEM:
return await handleDisconnectWorkItemViewSubmission(details, data);
case E_MESSAGE_ACTION_TYPES.ISSUE_COMMENT_SUBMISSION:
return await handleIssueCommentViewSubmission(details, data);
case E_MESSAGE_ACTION_TYPES.ISSUE_WEBLINK_SUBMISSION:
return await handleIssueWeblinkViewSubmission(details, data);
case E_MESSAGE_ACTION_TYPES.CREATE_NEW_WORK_ITEM:
return await handleCreateNewWorkItemViewSubmission(details, data);
default:
logger.error("Unknown view submission callback id:", data.view.callback_id);
return;
}
};
export const handleLinkWorkItemViewSubmission = async (details: SlackConnectionDetails, data: TViewSubmissionPayload) => {
const { slackService, planeClient } = details;
const linkWorkItemValues = parseLinkWorkItemFormData(data.view.state.values);
const metadata = JSON.parse(data.view.private_metadata) as SlackPrivateMetadata<typeof ENTITIES.LINK_WORK_ITEM>;
if (linkWorkItemValues) {
const issue = await planeClient.issue.getIssueWithFields(linkWorkItemValues.workspaceSlug, linkWorkItemValues.projectId, linkWorkItemValues.issueId, ["state", "project", "assignees", "labels"]);
// Check if the issue is already linked to a thread
const workspaceEntityConnection = await apiClient.workspaceEntityConnection.listWorkspaceEntityConnections({
workspace_connection_id: details.workspaceConnection.id,
issue_id: linkWorkItemValues.issueId,
entity_type: E_INTEGRATION_KEYS.SLACK,
});
if (workspaceEntityConnection.length > 0) {
// We need to update the view in order to show that the selected issue is already linked to a thread
const existingLink = workspaceEntityConnection[0];
const entityData = existingLink.entity_data as any;
const channelName = entityData?.channel?.name || 'a channel';
const threadLink = `https://${data.team.domain}.slack.com/archives/${entityData?.channel?.id}/p${existingLink.entity_id}`;
const issueLink = `${env.APP_BASE_URL}/${linkWorkItemValues.workspaceSlug}/browse/${issue.project.identifier}-${issue.sequence_id}`;
// Create rich error message with links
const errorMessage = createIssueErrorBlocks(issue, channelName, threadLink, issueLink);
const updatedModal = createLinkIssueModalView(metadata.entityPayload);
updatedModal.blocks = [...errorMessage.blocks, ...updatedModal.blocks];
const res = await slackService.openModal(data.trigger_id, updatedModal);
} else {
const title = "Connected to Slack thread";
const link = `https://${data.team.domain}.slack.com/archives/${metadata.entityPayload.channel.id}/p${metadata.entityPayload.message?.ts}`;
const states = await planeClient.state.list(linkWorkItemValues.workspaceSlug, linkWorkItemValues.projectId);
const linkBack = createSlackLinkback(
linkWorkItemValues.workspaceSlug,
issue,
states.results,
false
);
// Send a thread message to the channel
const linkbackPromise = slackService.sendThreadMessage(
metadata.entityPayload.channel.id,
metadata.entityPayload.message?.ts || "",
linkBack,
issue,
false
);
const createLinkInPlane = planeClient.issue.createLink(linkWorkItemValues.workspaceSlug, linkWorkItemValues.projectId, issue.id, title, link);
const createEntityConnection = apiClient.workspaceEntityConnection.createWorkspaceEntityConnection({
// Main workspace connection
workspace_connection_id: details.workspaceConnection.id,
// Issue identifiers
workspace_id: details.workspaceConnection.workspace_id,
project_id: linkWorkItemValues.projectId,
issue_id: issue.id,
// Entity identifiers
entity_type: E_INTEGRATION_KEYS.SLACK,
entity_id: metadata.entityPayload.message?.ts || "",
entity_data: metadata.entityPayload,
entity_slug: issue.id,
});
try {
await Promise.all([linkbackPromise, createLinkInPlane, createEntityConnection]);
} catch (error) {
logger.error("Error processing link work item view submission:", error);
}
}
}
}
export const handleDisconnectWorkItemViewSubmission = async (details: SlackConnectionDetails, data: TViewSubmissionPayload) => {
const { workspaceConnection, slackService, planeClient } = details;
const metadata = JSON.parse(data.view.private_metadata) as SlackPrivateMetadata<typeof ENTITIES.DISCONNECT_WORK_ITEM>;
const workspaceEntityConnection = await apiClient.workspaceEntityConnection.listWorkspaceEntityConnections({
workspace_connection_id: workspaceConnection.id,
entity_id: metadata.entityPayload.message?.ts || "",
});
if (workspaceEntityConnection.length > 0) {
// Disconnect the work item
await apiClient.workspaceEntityConnection.deleteWorkspaceEntityConnection(workspaceEntityConnection[0].id);
// Send a message to the channel
await slackService.sendThreadMessage(metadata.entityPayload.channel.id, metadata.entityPayload.message?.ts ?? "", {
text: "Work item disconnected from Slack thread.",
blocks: []
});
}
}
export const handleIssueCommentViewSubmission = async (details: SlackConnectionDetails, data: TViewSubmissionPayload) => {
const { workspaceConnection, slackService, planeClient } = details;
const metadata = JSON.parse(data.view.private_metadata) as SlackPrivateMetadata<typeof ENTITIES.ISSUE_COMMENT_SUBMISSION>;
const thread_ts = metadata.entityPayload.message?.thread_ts;
const channel = metadata.entityPayload.channel.id;
const user = metadata.entityPayload.user.id;
const value = metadata.entityPayload.value;
const message_ts = metadata.entityPayload.message_ts;
const metadata = JSON.parse(data.view.private_metadata) as SlackPrivateMetadata;
try {
const projects = await planeClient.project.list(workspaceConnection.workspace_slug);
projects.results.filter((project) => project.is_member === true);
const values = value.split(".");
const parsedData = parseIssueFormData(data.view.state.values);
if (metadata.entityPayload.type === "message_action" || metadata.entityPayload.type === "command_project_selection") {
try {
const slackUser = await slackService.getUserInfo(data.user.id);
const members = await planeClient.users.listAllUsers(workspaceConnection.workspace_slug);
const member = members.find((member) => member.email === slackUser?.user.profile.email);
/* ==================== Create Issue ==================== */
const issue = await planeClient.issue.create(workspaceConnection.workspace_slug, parsedData.project, {
name: parsedData.title,
description_html: parsedData.description == null ? "<p></p>" : parsedData.description,
created_by: member?.id,
state: parsedData.state,
priority: parsedData.priority,
labels: parsedData.labels,
});
const project = await planeClient.project.getProject(workspaceConnection.workspace_slug, parsedData.project);
const states = await planeClient.state.list(workspaceConnection.workspace_slug, issue.project);
const linkBack = createSlackLinkback(
workspaceConnection.workspace_slug,
project,
members,
states.results,
issue,
parsedData.enableThreadSync || false
);
if (metadata.entityType === ENTITIES.ISSUE_SUBMISSION && metadata.entityPayload.type === "message_action") {
/* ==================== Send Linkback ==================== */
const res = await slackService.sendThreadMessage(
metadata.entityPayload.channel.id,
metadata.entityPayload.message?.ts,
linkBack,
issue,
false
);
// If thread sync is enabled and the response is ok for the linkback
if (parsedData.enableThreadSync && res.ok) {
// If thread sync is enabled, create an entity connection for the issue
// check if the connection already exists
const connections = await apiClient.workspaceEntityConnection.listWorkspaceEntityConnections({
entity_id: res.message.thread_ts,
});
if (!connections || connections.length === 0) {
await apiClient.workspaceEntityConnection.createWorkspaceEntityConnection({
workspace_id: workspaceConnection.workspace_id,
workspace_connection_id: workspaceConnection.id,
project_id: parsedData.project,
entity_type: E_INTEGRATION_KEYS.SLACK,
entity_id: res.message.thread_ts,
entity_data: res,
entity_slug: issue.id,
issue_id: issue.id,
});
}
} else {
logger.error("Error sending thread message:", res);
}
/* ==================== File Upload ==================== */
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) => {
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);
}
}
}
} else if (metadata.entityPayload.type === "command_project_selection") {
if (metadata.entityPayload.response_url)
await slackService.sendMessage(metadata.entityPayload.response_url, {
text: "Issue successfully created. 😄",
blocks: linkBack.blocks,
});
}
} catch (error: any) {
const isPermissionError = error?.detail?.includes(CONSTANTS.NO_PERMISSION_ERROR);
if (isPermissionError) {
logger.error("Permission error in handleViewSubmission:", error);
} else {
throw error;
}
}
} else if (metadata.entityType === ENTITIES.ISSUE_COMMENT_SUBMISSION) {
const thread_ts = metadata.entityPayload.message?.thread_ts;
const channel = metadata.entityPayload.channel.id;
const user = metadata.entityPayload.user.id;
const value = metadata.entityPayload.value;
const message_ts = metadata.entityPayload.message_ts;
try {
const values = value.split(".");
if (values.length === 2) {
const projectId = values[0];
const issueId = values[1];
let comment = "";
Object.entries(data.view.state.values).forEach(([_, blockData]: [string, any]) => {
if (blockData.comment_submit?.type === "plain_text_input") {
comment = blockData.comment_submit.value;
}
});
const slackUser = await slackService.getUserInfo(user);
const projectMembers = await planeClient.users.list(workspaceConnection.workspace_slug, projectId);
const member = projectMembers.find((member) => member.email === slackUser?.user.profile.email);
await planeClient.issueComment.create(workspaceConnection.workspace_slug, projectId, issueId, {
comment_html: "<p>" + comment + "</p>",
created_by: member?.id,
external_source: "SLACK-PRIVATE_COMMENT",
external_id: data.view.id,
});
if (thread_ts) {
await slackService.sendEphemeralMessage(user, `Comment successfully added to issue.`, channel, thread_ts);
} else {
await slackService.sendThreadMessage(channel, message_ts, `Comment successfully added to issue.`);
}
}
} catch (error: any) {
const isPermissionError = error?.detail?.includes(CONSTANTS.NO_PERMISSION_ERROR);
const errorMessage = isPermissionError
? CONSTANTS.NO_PERMISSION_ERROR_MESSAGE
: CONSTANTS.SOMETHING_WENT_WRONG;
await slackService.sendEphemeralMessage(data.user.id, errorMessage, channel, thread_ts);
if (!isPermissionError) {
throw error;
}
}
} else if (metadata.entityType === ENTITIES.ISSUE_WEBLINK_SUBMISSION) {
const thread_ts = metadata.entityPayload.message?.thread_ts;
const user = metadata.entityPayload.user.id;
const channel = metadata.entityPayload.channel.id;
const message_ts = metadata.entityPayload.message_ts;
const value = metadata.entityPayload.value;
const values = value.split(".");
if (values.length === 2) {
const projectId = values[0];
const issueId = values[1];
let label = "";
let url = "";
try {
let comment = "";
Object.entries(data.view.state.values).forEach(([_, blockData]: [string, any]) => {
Object.entries(blockData).forEach(([_, values]: [string, any]) => {
if (values.type === "plain_text_input") {
label = values.value;
} else if (values.type === "url_text_input") {
url = values.value;
}
});
});
const message = `Link <${url}|${label}> successfully added to issue.`;
await planeClient.issue.createLink(workspaceConnection.workspace_slug, projectId, issueId, label, url);
if (thread_ts) {
await slackService.sendEphemeralMessage(user, message, channel, thread_ts);
} else {
await slackService.sendThreadMessage(channel, message_ts, message);
Object.entries(data.view.state.values).forEach(([_, blockData]: [string, any]) => {
if (blockData.comment_submit?.type === "plain_text_input") {
comment = blockData.comment_submit.value;
}
} catch (error: any) {
const isPermissionError = error?.detail?.includes(CONSTANTS.NO_PERMISSION_ERROR);
const errorMessage = isPermissionError
? CONSTANTS.NO_PERMISSION_ERROR_MESSAGE
: CONSTANTS.SOMETHING_WENT_WRONG;
});
await slackService.sendEphemeralMessage(data.user.id, errorMessage, channel, thread_ts);
const slackUser = await slackService.getUserInfo(user);
const projectMembers = await planeClient.users.list(workspaceConnection.workspace_slug, projectId);
if (!isPermissionError) {
throw error;
}
const member = projectMembers.find((member) => member.email === slackUser?.user.profile.email);
await planeClient.issueComment.create(workspaceConnection.workspace_slug, projectId, issueId, {
comment_html: "<p>" + comment + "</p>",
created_by: member?.id,
external_source: "SLACK-PRIVATE_COMMENT",
external_id: data.view.id,
});
if (thread_ts) {
await slackService.sendEphemeralMessage(user, `Comment successfully added to issue.`, channel, thread_ts);
} else {
await slackService.sendThreadMessage(channel, message_ts, `Comment successfully added to issue.`);
}
}
} catch (error: any) {
logger.error("Unexpected error in handleViewSubmission:", error);
throw error;
const isPermissionError = error?.detail?.includes(CONSTANTS.NO_PERMISSION_ERROR);
const errorMessage = isPermissionError
? CONSTANTS.NO_PERMISSION_ERROR_MESSAGE
: CONSTANTS.SOMETHING_WENT_WRONG;
await slackService.sendEphemeralMessage(data.user.id, errorMessage, channel, thread_ts);
if (!isPermissionError) {
throw error;
}
}
}
export const handleIssueWeblinkViewSubmission = async (details: SlackConnectionDetails, data: TViewSubmissionPayload) => {
const { workspaceConnection, slackService, planeClient } = details;
const metadata = JSON.parse(data.view.private_metadata) as SlackPrivateMetadata<typeof ENTITIES.ISSUE_WEBLINK_SUBMISSION>;
const thread_ts = metadata.entityPayload.message?.thread_ts;
const user = metadata.entityPayload.user.id;
const channel = metadata.entityPayload.channel.id;
const message_ts = metadata.entityPayload.message_ts;
const value = metadata.entityPayload.value;
const values = value.split(".");
const projectId = values[0];
const issueId = values[1];
let label = "";
let url = "";
try {
Object.entries(data.view.state.values).forEach(([_, blockData]: [string, any]) => {
Object.entries(blockData).forEach(([_, values]: [string, any]) => {
if (values.type === "plain_text_input") {
label = values.value;
} else if (values.type === "url_text_input") {
url = values.value;
}
});
});
const message = `Link <${url}|${label}> successfully added to issue.`;
await planeClient.issue.createLink(workspaceConnection.workspace_slug, projectId, issueId, label, url);
if (thread_ts) {
await slackService.sendEphemeralMessage(user, message, channel, thread_ts);
} else {
await slackService.sendThreadMessage(channel, message_ts, message);
}
} catch (error: any) {
const isPermissionError = error?.detail?.includes(CONSTANTS.NO_PERMISSION_ERROR);
const errorMessage = isPermissionError
? CONSTANTS.NO_PERMISSION_ERROR_MESSAGE
: CONSTANTS.SOMETHING_WENT_WRONG;
await slackService.sendEphemeralMessage(data.user.id, errorMessage, channel, thread_ts);
if (!isPermissionError) {
throw error;
}
}
}
export const handleCreateNewWorkItemViewSubmission = async (details: SlackConnectionDetails, data: TViewSubmissionPayload) => {
const { workspaceConnection, slackService, planeClient } = details;
try {
const parsedData = parseIssueFormData(data.view.state.values);
const metadata = JSON.parse(data.view.private_metadata) as SlackPrivateMetadata;
if (metadata.entityType !== ENTITIES.SHORTCUT_PROJECT_SELECTION && metadata.entityType !== ENTITIES.COMMAND_PROJECT_SELECTION) {
logger.info(`[SLACK] Unsupported payload type: ${metadata.entityType}`);
return;
}
const slackUser = await slackService.getUserInfo(data.user.id);
const members = await planeClient.users.listAllUsers(workspaceConnection.workspace_slug);
const member = members.find(m => m.email === slackUser?.user.profile.email);
const issue = await planeClient.issue.create(
workspaceConnection.workspace_slug,
parsedData.project,
{
name: parsedData.title,
description_html: parsedData.description == null ? "<p></p>" : parsedData.description,
created_by: member?.id,
state: parsedData.state,
priority: parsedData.priority,
labels: parsedData.labels,
}
);
const issueWithFields = await planeClient.issue.getIssueWithFields(
workspaceConnection.workspace_slug,
parsedData.project,
issue.id,
["state", "project", "assignees", "labels"]
);
const states = await planeClient.state.list(
workspaceConnection.workspace_slug,
issue.project
);
const linkBack = createSlackLinkback(
workspaceConnection.workspace_slug,
issueWithFields,
states.results,
parsedData.enableThreadSync || false
);
// Step 6: Send the appropriate response based on entity type
if (metadata.entityType === ENTITIES.SHORTCUT_PROJECT_SELECTION) {
/* For shortcut project selection,
- Send a thread message to the channel
- Create a thread connection if thread sync is enabled
Hence, we need to handle the shortcut project selection in a separate function
*/
await handleShortcutProjectSelection(
slackService,
apiClient,
workspaceConnection,
parsedData,
metadata as SlackPrivateMetadata<typeof ENTITIES.SHORTCUT_PROJECT_SELECTION>,
linkBack,
issue,
credentials
);
} else if (metadata.entityType === ENTITIES.COMMAND_PROJECT_SELECTION) {
/* For command project selection,
- Send a message to the response_url, directly to the channel
Hence, we need to handle the command project selection in a separate function
*/
await handleCommandProjectSelection(
slackService,
metadata as SlackPrivateMetadata<typeof ENTITIES.COMMAND_PROJECT_SELECTION>,
linkBack
);
}
} catch (error: any) {
// Handle any errors
const isPermissionError = error?.detail?.includes(CONSTANTS.NO_PERMISSION_ERROR);
if (isPermissionError) {
logger.error("Permission error in handleCreateNewWorkItemViewSubmission:", error);
} else {
logger.error("Unexpected error in handleCreateNewWorkItemViewSubmission:", error);
throw error;
}
}
};
// Helper function for shortcut project selection handling
async function handleShortcutProjectSelection(
slackService: any,
apiClient: any,
workspaceConnection: any,
parsedData: any,
metadata: SlackPrivateMetadata<typeof ENTITIES.SHORTCUT_PROJECT_SELECTION>,
linkBack: any,
issue: any,
credentials: any
) {
// Send thread message
const res = await slackService.sendThreadMessage(
metadata.entityPayload.channel.id,
metadata.entityPayload.message?.ts || "",
linkBack,
issue,
false
);
// Handle thread sync if enabled
if (parsedData.enableThreadSync && res.ok) {
await createThreadConnection(
apiClient,
workspaceConnection,
parsedData.project,
issue.id,
res
);
}
}
// Helper function for command project selection handling
async function handleCommandProjectSelection(
slackService: any,
metadata: SlackPrivateMetadata<typeof ENTITIES.COMMAND_PROJECT_SELECTION>,
linkBack: any
) {
if (metadata.entityPayload.response_url) {
await slackService.sendMessage(metadata.entityPayload.response_url, {
text: "Issue successfully created. 😄",
blocks: linkBack.blocks,
});
}
}
// Helper function to create thread connection
async function createThreadConnection(
apiClient: any,
workspaceConnection: any,
projectId: string,
issueId: string,
res: any
) {
const connections = await apiClient.workspaceEntityConnection.listWorkspaceEntityConnections({
entity_id: res.message.thread_ts,
});
if (!connections || connections.length === 0) {
await apiClient.workspaceEntityConnection.createWorkspaceEntityConnection({
workspace_id: workspaceConnection.workspace_id,
workspace_connection_id: workspaceConnection.id,
project_id: projectId,
entity_type: E_INTEGRATION_KEYS.SLACK,
entity_id: res.message.thread_ts,
entity_data: res,
entity_slug: issueId,
issue_id: issueId,
});
}
}
export const createIssueErrorBlocks = (issue: IssueWithExpanded<["state", "project", "assignees", "labels"]>, channelName: string, threadLink: string, issueLink: string) => {
const errorMessage = {
blocks: [
{
type: "header",
text: {
type: "plain_text",
text: "Work Item Already Linked",
emoji: true
}
},
{
type: "section",
text: {
type: "mrkdwn",
text: `:warning: The work item *<${issueLink}|[${issue.project.identifier}-${issue.sequence_id}] ${issue.name}>* is already linked to another conversation in #${channelName}.`
}
},
{
type: "actions",
elements: [
{
type: "button",
text: {
type: "plain_text",
text: "View Linked Thread",
emoji: true
},
url: threadLink,
style: "primary"
},
{
type: "button",
text: {
type: "plain_text",
text: "View Work Item",
emoji: true
},
url: issueLink
}
]
}
]
};
return errorMessage;
}

View File

@@ -16,7 +16,7 @@ const handleCommentSync = async (payload: WebhookIssueCommentPayload) => {
const [entityConnection] = await apiClient.workspaceEntityConnection.listWorkspaceEntityConnections({
workspace_id: data.data.workspace,
project_id: data.data.project,
entity_slug: data.data.issue,
issue_id: data.data.issue,
});
/*
@@ -37,20 +37,23 @@ const handleCommentSync = async (payload: WebhookIssueCommentPayload) => {
const slackData = entityConnection.entity_data as SlackMessageResponse;
const details = await getConnectionDetails(slackData.message.team);
if (!details) return logger.info(`[SLACK] No connection details found for team ${slackData.message.team}`);
const { slackService } = details;
const channel = typeof slackData.channel === "string" ? slackData.channel : slackData.channel?.["id"];
if (credentials && credentials.length > 0 && credentials[0].source_access_token) {
await slackService.sendMessageAsUser(
slackData.channel,
channel,
entityConnection.entity_id ?? "",
data.data.comment_stripped,
credentials[0].source_access_token
);
} else {
await slackService.sendThreadMessage(
slackData.channel,
channel,
entityConnection.entity_id ?? "",
data.data.comment_stripped
);

View File

@@ -0,0 +1,181 @@
import { Store } from "@/worker/base";
import { PlaneWebhookPayload } from "@plane/sdk";
import { getConnectionDetailsForIssue } from "../../helpers/connection-details";
import { ActivityForSlack, PlaneActivityWithTimestamp } from "../../types/types";
const ignoredFieldUpdates = ["description", "attachment"];
export const handleIssueWebhook = async (payload: PlaneWebhookPayload) => {
const activities = await getActivities(payload);
// Ideally we won't hit this case, but just in case
if (activities.length === 0) {
return;
}
const details = await getConnectionDetailsForIssue(payload);
if (!details) {
return;
}
const { slackService, entityConnection } = details;
const message = createSlackBlocksFromActivity(activities);
const entityData = entityConnection.entity_data as any;
const channel = entityData.channel.id;
const messageTs = entityData.message.ts;
if (message.length === 0) {
return;
}
await slackService.sendThreadMessage(channel, messageTs, {
text: "Work Item Updated",
blocks: message,
});
};
export const createSlackBlocksFromActivity = (fields: ActivityForSlack[]) => {
// Sort the fields so that array fields are at the end
fields.sort((a, b) => (a.isArrayField ? 1 : -1));
let message = "Work item updated 🔄\n";
// Filter out fields that should be ignored
fields = fields.filter((field) => !shouldIgnoreField(field.field, ignoredFieldUpdates));
if (fields.length === 0) {
return [];
}
fields.forEach((field) => {
const capitalize = (str: string) => {
return str.charAt(0).toUpperCase() + str.slice(1);
};
const cleanField = capitalize(field.field.replace("_", " "));
if (field.isArrayField) {
if (field.added.length > 0 || field.removed.length > 0) {
message += `\n• *${cleanField} updated:*`;
if (field.added.length > 0) {
message += `\n • Added _${field.added.join(", ")}_`;
}
if (field.removed.length > 0) {
message += `\n • Removed _${field.removed.join(", ")}_`;
}
}
} else {
message += `\n• *${cleanField}* updated to *${field.newValue}*`;
}
});
const blocks = [
{
type: "section",
text: {
type: "mrkdwn",
text: message,
},
},
];
return blocks;
};
export const getActivities = async (payload: PlaneWebhookPayload): Promise<ActivityForSlack[]> => {
const store = Store.getInstance();
const key = `slack:issue:${payload.id}`;
const activityList = await store.getList(key);
if (!activityList) {
return [];
}
await store.del(key);
const activities = activityList.map((activity) => JSON.parse(activity) as PlaneActivityWithTimestamp);
// Sort the activities by timestamp
activities.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
/*
Group all the activities such that we can process each of them in a
single pass whether it's an array field or a singular field
*/
const fieldGroups = new Map<string, PlaneActivityWithTimestamp[]>();
activities.forEach((activity) => {
if (!fieldGroups.has(activity.field)) {
fieldGroups.set(activity.field, []);
}
fieldGroups.get(activity.field)!.push(activity);
});
const latestActivities = new Map<string, ActivityForSlack>();
/*
For each field group, if it's an array field, we need to get the added and removed values
If it's a singular field, we just need to get the latest value
*/
for (const [field, fieldActivities] of fieldGroups.entries()) {
const isArrayField = field.endsWith("s");
const actor = fieldActivities[0].actor.display_name;
if (isArrayField) {
const { added, removed } = getArrayActivity(fieldActivities);
latestActivities.set(field, {
isArrayField: true,
field,
actor,
added,
removed,
});
} else {
// For singular fields, use the last activity's new_value
const latestActivity = fieldActivities[fieldActivities.length - 1];
latestActivities.set(field, {
isArrayField: false,
field,
actor,
newValue: latestActivity.new_value || "",
});
}
}
// Return the activities
return Array.from(latestActivities.values());
};
export const getArrayActivity = (activities: PlaneActivityWithTimestamp[]) => {
/*
According to how plane shares the activity, if the old value is null and new values is something, something is added
If the old value is something and new value is null, something is removed, we just need to collect those values with
two arrays removed and added values
*/
const added: string[] = [];
const removed: string[] = [];
for (const activity of activities) {
if (activity.old_value === null && activity.new_value !== null) {
added.push(activity.new_value);
} else if (activity.old_value !== null && activity.new_value === null) {
removed.push(activity.old_value);
}
}
return { added, removed };
};
// Helper function to check if a field should be ignored
const shouldIgnoreField = (fieldName: string, ignoredFieldUpdates: string[]): boolean => {
// Check if any ignored field is a prefix of this field
// This handles cases like "description" also ignoring "description_html"
for (const ignoredField of ignoredFieldUpdates) {
if (fieldName === ignoredField || fieldName.startsWith(ignoredField + "_")) {
return true;
}
}
return false;
};

View File

@@ -1,8 +1,9 @@
import { MQ, Store } from "@/worker/base";
import { TaskHandler, TaskHeaders } from "@/types";
import { PlaneWebhookData, WebhookIssueCommentPayload } from "@plane/sdk";
import { PlaneWebhookPayload, WebhookIssueCommentPayload } from "@plane/sdk";
import { handleIssueCommentWebhook } from "./plane-webhook-handlers/handle-comment-webhook";
import { logger } from "@/logger";
import { handleIssueWebhook } from "./plane-webhook-handlers/handle-issue-webhook";
export class PlaneSlackWebhookWorker extends TaskHandler {
mq: MQ;
@@ -13,9 +14,12 @@ export class PlaneSlackWebhookWorker extends TaskHandler {
this.mq = mq;
this.store = store;
}
async handleTask(headers: TaskHeaders, data: PlaneWebhookData): Promise<boolean> {
async handleTask(headers: TaskHeaders, data: any): Promise<boolean> {
try {
switch (data.event) {
case "issue":
await handleIssueWebhook(data as PlaneWebhookPayload);
break
case "issue_comment":
await handleIssueCommentWebhook(data as WebhookIssueCommentPayload);
break;

View File

@@ -115,6 +115,10 @@ export class Store extends EventEmitter {
return await this.client.get(key);
}
public async getList(key: string): Promise<string[] | null> {
return await this.client.lRange(key, 0, -1);
}
public async set(key: string, value: string, ttl?: number, NX = true): Promise<boolean> {
this.jobs.push(key);
@@ -127,6 +131,29 @@ export class Store extends EventEmitter {
}
}
public async setList(key: string, value: string, ttl?: number, NX = true): Promise<boolean> {
try {
const exists = await this.client.exists(key) > 0;
if (NX && exists) return false;
// Atomic operation: delete if exists, then push new items
const multi = this.client.multi();
// Push the value to the list
multi.rPush(key, value);
// Set the expiration time
if (ttl) multi.expire(key, ttl);
// Execute the transaction
await multi.exec();
return true;
} catch (error) {
logger.error(`Error in setList for key ${key}:`, error);
return false;
}
}
public async del(key: string): Promise<number> {
const index = this.jobs.indexOf(key);
if (index > -1) {