mirror of
https://github.com/makeplane/plane.git
synced 2026-07-13 22:09:12 +02:00
[SILO-178] feat: slack issue linking with threads with Plane updates synced with slack threads
This commit is contained in:
@@ -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(),
|
||||
|
||||
@@ -11,6 +11,7 @@ from .issue import (
|
||||
IssueActivityAPIEndpoint,
|
||||
IssueAttachmentEndpoint,
|
||||
IssueAttachmentServerEndpoint,
|
||||
IssueSearchEndpoint,
|
||||
)
|
||||
|
||||
from .cycle import (
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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/`
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
})
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
});
|
||||
|
||||
120
silo/src/apps/slack/views/link-issue-modal.ts
Normal file
120
silo/src/apps/slack/views/link-issue-modal.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user