@taazkareem/clickup-mcp-server 0.4.75 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,13 +1,18 @@
1
1
  <img src="https://clickup.com/assets/brand/logo-v3-clickup-dark.svg" alt="ClickUp" height="40" style="vertical-align: middle; margin-top: -4px;">
2
2
 
3
3
  # MCP Server
4
+
5
+ [![GitHub Stars](https://img.shields.io/github/stars/TaazKareem/clickup-mcp-server?style=flat&logo=github)](https://github.com/TaazKareem/clickup-mcp-server/stargazers)
6
+ [![Maintenance](https://img.shields.io/badge/Maintained%3F-yes-green.svg)](https://github.com/TaazKareem/clickup-mcp-server/graphs/commit-activity)
7
+
4
8
  A Model Context Protocol (MCP) server for integrating ClickUp tasks with AI applications. This server allows AI agents to interact with ClickUp tasks, spaces, lists, and folders through a standardized protocol.
5
9
 
6
- > 🚧 **Status Update:** -Added get_task_comments tool -Improved task name matching -Fixed workspace hierarchy display
7
10
 
8
- <a href="https://glama.ai/mcp/servers/iwjvs2zy63">
9
- <img width="380" height="200" src="https://glama.ai/mcp/servers/iwjvs2zy63/badge" alt="ClickUp Server MCP server" />
10
- </a>
11
+ > 🚧 **Status Update:** -v0.5.1 beta release with new Attach Task Files tool, Create Task Comments tool, Get Task Comments tool, and Custom ID support across all tools.
12
+
13
+
14
+
15
+
11
16
 
12
17
  ## Setup
13
18
 
@@ -15,6 +20,7 @@ A Model Context Protocol (MCP) server for integrating ClickUp tasks with AI appl
15
20
  - ClickUp API key from [ClickUp Settings](https://app.clickup.com/settings/apps)
16
21
  - Team ID from your ClickUp workspace URL
17
22
  2. Choose either hosted installation (sends webhooks) or NPX installation (downloads to local path and installs dependencies)
23
+ 3. Use natural language to manage your workspace!
18
24
 
19
25
  ## Smithery Installation (Quick Start)
20
26
 
@@ -25,6 +31,10 @@ The server is hosted on Smithery. There, you can preview the available tools or
25
31
 
26
32
  ## NPX Installation
27
33
 
34
+ [![NPM Version](https://img.shields.io/npm/v/@taazkareem/clickup-mcp-server.svg?style=flat&logo=npm)](https://www.npmjs.com/package/@taazkareem/clickup-mcp-server)
35
+ [![Dependency Status](https://img.shields.io/librariesio/github/TaazKareem/clickup-mcp-server)](https://libraries.io/github/TaazKareem/clickup-mcp-server)
36
+ [![NPM Downloads](https://img.shields.io/npm/dm/@taazkareem/clickup-mcp-server.svg?style=flat&logo=npm)](https://npmcharts.com/compare/@taazkareem/clickup-mcp-server?minimal=true)
37
+
28
38
  Add this entry to your client's MCP settings JSON file:
29
39
 
30
40
  ```json
@@ -52,17 +62,17 @@ Or use this npx command:
52
62
  ## Features
53
63
 
54
64
  - 🎯 **Task Management**
55
- - Create, update, and delete individual tasks
65
+ - Create, retrieve, update, and delete tasks
56
66
  - Move and duplicate tasks between lists, spaces, and folders
57
- - Create, update, move, and delete multiple tasks in bulk
67
+ - Single operation or bulk operation
58
68
  - View and modify task details and properties
59
69
  - Get task comments
60
70
  - Set due dates using natural language and relative time expressions
71
+ - Attach files to tasks using local file paths, URL, base64, or chunked uploads
61
72
 
62
73
  - 📂 **Workspace Organization**
63
74
  - Complete workspace hierarchy (spaces, folders, lists)
64
75
  - Tree structure with clear relationships
65
- - Full CRUD operations for workspace components
66
76
  - Efficient path-based navigation
67
77
 
68
78
  - 🔄 **Integration Features**
@@ -83,6 +93,8 @@ Or use this npx command:
83
93
  | [get_tasks](docs/api-reference.md#task-management) | Get tasks from list | `listId`/`listName` |
84
94
  | [get_task](docs/api-reference.md#task-management) | Get task details | `taskId`/`taskName` |
85
95
  | [get_task_comments](docs/api-reference.md#task-management) | Get comments on a task | `taskId`/`taskName` |
96
+ | [create_task_comment](docs/api-reference.md#task-management) | Add a comment to a task | `commentText`, (`taskId`/(`taskName`+`listName`)) |
97
+ | [attach_task_file](docs/api-reference.md#task-management) | Attach file to a task | `taskId`/`taskName`, (`file_data` or `file_url`) |
86
98
  | [delete_task](docs/api-reference.md#task-management) | Remove task | `taskId`/`taskName` |
87
99
  | [delete_bulk_tasks](docs/api-reference.md#task-management) | Remove multiple tasks | `tasks[]` with IDs or names |
88
100
  | [move_task](docs/api-reference.md#task-management) | Move task | `taskId`/`taskName`, `listId`/`listName` |
@@ -126,6 +138,10 @@ If you find this project useful, please consider supporting:
126
138
 
127
139
  [![Sponsor TaazKareem](https://img.shields.io/badge/Sponsor-TaazKareem-orange?logo=github)](https://github.com/sponsors/TaazKareem)
128
140
 
141
+ <a href="https://buymeacoffee.com/taazkareem">
142
+ <img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" width="200" alt="Buy Me A Coffee">
143
+ </a>
144
+
129
145
  ## Acknowledgements
130
146
 
131
147
  Special thanks to [ClickUp](https://clickup.com) for their excellent API and services that make this integration possible.
@@ -136,6 +152,8 @@ Contributions are welcome! Please read our [Contributing Guide](CONTRIBUTING.md)
136
152
 
137
153
  ## License
138
154
 
155
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
156
+
139
157
  This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
140
158
 
141
159
  ## Disclaimer
package/build/index.js CHANGED
@@ -19,12 +19,6 @@
19
19
  * - Smart name-based lookups that eliminate the need for IDs
20
20
  * - Support for priorities, statuses, and due dates
21
21
  *
22
- * AI-Enhanced Capabilities:
23
- * - Task summarization and status grouping for project overviews
24
- * - Priority analysis and optimization for workload balancing
25
- * - Detailed task description generation with structured content
26
- * - Task relationship identification for dependency management
27
- *
28
22
  * Technical Features:
29
23
  * - Full markdown support for rich text content
30
24
  * - Secure credential handling through configuration
package/build/server.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
2
2
  import { CallToolRequestSchema, ListToolsRequestSchema, ListPromptsRequestSchema, GetPromptRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
3
3
  import { workspaceHierarchyTool, handleGetWorkspaceHierarchy } from "./tools/workspace.js";
4
- import { createTaskTool, updateTaskTool, moveTaskTool, duplicateTaskTool, getTaskTool, getTasksTool, deleteTaskTool, getTaskCommentsTool, createBulkTasksTool, updateBulkTasksTool, moveBulkTasksTool, deleteBulkTasksTool, handleCreateTask, handleUpdateTask, handleMoveTask, handleDuplicateTask, handleGetTasks, handleDeleteTask, handleGetTaskComments, handleCreateBulkTasks, handleUpdateBulkTasks, handleMoveBulkTasks, handleDeleteBulkTasks, handleGetTask } from "./tools/task/index.js";
4
+ import { createTaskTool, updateTaskTool, moveTaskTool, duplicateTaskTool, getTaskTool, getTasksTool, deleteTaskTool, getTaskCommentsTool, createTaskCommentTool, createBulkTasksTool, updateBulkTasksTool, moveBulkTasksTool, deleteBulkTasksTool, attachTaskFileTool, handleCreateTask, handleUpdateTask, handleMoveTask, handleDuplicateTask, handleGetTasks, handleDeleteTask, handleGetTaskComments, handleCreateTaskComment, handleCreateBulkTasks, handleUpdateBulkTasks, handleMoveBulkTasks, handleDeleteBulkTasks, handleGetTask, handleAttachTaskFile } from "./tools/task/index.js";
5
5
  import { createListTool, handleCreateList, createListInFolderTool, handleCreateListInFolder, getListTool, handleGetList, updateListTool, handleUpdateList, deleteListTool, handleDeleteList } from "./tools/list.js";
6
6
  import { createFolderTool, handleCreateFolder, getFolderTool, handleGetFolder, updateFolderTool, handleUpdateFolder, deleteFolderTool, handleDeleteFolder } from "./tools/folder.js";
7
7
  import { Logger } from "./logger.js";
@@ -15,7 +15,7 @@ const { workspace } = clickUpServices;
15
15
  */
16
16
  export const server = new Server({
17
17
  name: "clickup-mcp-server",
18
- version: "0.4.75",
18
+ version: "0.5.1",
19
19
  }, {
20
20
  capabilities: {
21
21
  tools: {},
@@ -41,6 +41,8 @@ export function configureServer() {
41
41
  duplicateTaskTool,
42
42
  deleteTaskTool,
43
43
  getTaskCommentsTool,
44
+ createTaskCommentTool,
45
+ attachTaskFileTool,
44
46
  createBulkTasksTool,
45
47
  updateBulkTasksTool,
46
48
  moveBulkTasksTool,
@@ -59,7 +61,7 @@ export function configureServer() {
59
61
  });
60
62
  // Register CallTool handler with proper logging
61
63
  logger.info("Registering tool handlers", {
62
- toolCount: 22,
64
+ toolCount: 23,
63
65
  categories: ["workspace", "task", "list", "folder"]
64
66
  });
65
67
  server.setRequestHandler(CallToolRequestSchema, async (req) => {
@@ -89,6 +91,10 @@ export function configureServer() {
89
91
  return handleDeleteTask(params);
90
92
  case "get_task_comments":
91
93
  return handleGetTaskComments(params);
94
+ case "create_task_comment":
95
+ return handleCreateTaskComment(params);
96
+ case "attach_task_file":
97
+ return handleAttachTaskFile(params);
92
98
  case "create_bulk_tasks":
93
99
  return handleCreateBulkTasks(params);
94
100
  case "update_bulk_tasks":
@@ -373,6 +373,67 @@ export class TaskService extends BaseClickUpService {
373
373
  throw this.handleError(error, 'Failed to get task comments');
374
374
  }
375
375
  }
376
+ /**
377
+ * Create a comment on a task
378
+ *
379
+ * @param taskId ID of the task to comment on
380
+ * @param commentText Text content of the comment
381
+ * @param notifyAll Whether to notify all assignees
382
+ * @param assignee Optional user ID to assign the comment to
383
+ * @returns The created comment
384
+ */
385
+ async createTaskComment(taskId, commentText, notifyAll = false, assignee) {
386
+ this.logOperation('createTaskComment', { taskId, commentText, notifyAll, assignee });
387
+ try {
388
+ const payload = {
389
+ comment_text: commentText,
390
+ notify_all: notifyAll
391
+ };
392
+ if (assignee) {
393
+ payload.assignee = assignee;
394
+ }
395
+ // Make the request directly without using makeRequest for better error handling
396
+ const response = await this.client.post(`/task/${taskId}/comment`, payload);
397
+ // Handle different response formats from ClickUp API
398
+ if (response.data) {
399
+ if (response.data.comment) {
400
+ // Standard format: { comment: ClickUpComment }
401
+ return response.data.comment;
402
+ }
403
+ else if (response.data.id && (response.data.comment_text || response.data.comment)) {
404
+ // Direct format: the comment object itself
405
+ return response.data;
406
+ }
407
+ else {
408
+ // Fallback: construct a minimal valid comment object
409
+ return {
410
+ id: response.data.id || `custom-${Date.now()}`,
411
+ comment: response.data.comment || commentText,
412
+ comment_text: response.data.comment_text || commentText,
413
+ user: response.data.user || { id: 0, username: 'Unknown', email: '', color: '' },
414
+ date: response.data.date || new Date().toISOString(),
415
+ resolved: false
416
+ };
417
+ }
418
+ }
419
+ throw new Error('Invalid response from ClickUp API');
420
+ }
421
+ catch (error) {
422
+ // Check if comment might have been created despite error
423
+ if (error.response?.status === 200 || error.response?.status === 201) {
424
+ // Try to construct a comment object from what we know
425
+ return {
426
+ id: `fallback-${Date.now()}`,
427
+ comment: commentText,
428
+ comment_text: commentText,
429
+ user: { id: 0, username: 'Unknown', email: '', color: '' },
430
+ date: new Date().toISOString(),
431
+ resolved: false
432
+ };
433
+ }
434
+ throw this.handleError(error, 'Failed to create task comment');
435
+ }
436
+ }
376
437
  /**
377
438
  * Validate that a list exists
378
439
  *
@@ -394,4 +455,84 @@ export class TaskService extends BaseClickUpService {
394
455
  throw new ClickUpServiceError(`Failed to validate list existence: ${error instanceof Error ? error.message : String(error)}`, ErrorCode.UNKNOWN, { listId });
395
456
  }
396
457
  }
458
+ /**
459
+ * Upload a file attachment to a ClickUp task
460
+ * @param taskId The ID of the task to attach the file to
461
+ * @param fileData The file data as a Buffer
462
+ * @param fileName The name of the file
463
+ * @returns Promise resolving to the attachment response from ClickUp
464
+ */
465
+ async uploadTaskAttachment(taskId, fileData, fileName) {
466
+ this.logOperation('uploadTaskAttachment', { taskId, fileName, fileSize: fileData.length });
467
+ try {
468
+ return await this.makeRequest(async () => {
469
+ // Create FormData for multipart/form-data upload
470
+ const FormData = (await import('form-data')).default;
471
+ const formData = new FormData();
472
+ // Add the file to the form data
473
+ formData.append('attachment', fileData, {
474
+ filename: fileName,
475
+ contentType: 'application/octet-stream' // Let ClickUp determine the content type
476
+ });
477
+ // Use the raw axios client for this request since we need to handle FormData
478
+ const response = await this.client.post(`/task/${taskId}/attachment`, formData, {
479
+ headers: {
480
+ ...formData.getHeaders(),
481
+ 'Authorization': this.apiKey
482
+ }
483
+ });
484
+ return response.data;
485
+ });
486
+ }
487
+ catch (error) {
488
+ throw this.handleError(error, `Failed to upload attachment to task ${taskId}`);
489
+ }
490
+ }
491
+ /**
492
+ * Upload a file attachment to a ClickUp task from a URL
493
+ * @param taskId The ID of the task to attach the file to
494
+ * @param fileUrl The URL of the file to download and attach
495
+ * @param fileName Optional file name (if not provided, it will be extracted from the URL)
496
+ * @param authHeader Optional authorization header for the URL
497
+ * @returns Promise resolving to the attachment response from ClickUp
498
+ */
499
+ async uploadTaskAttachmentFromUrl(taskId, fileUrl, fileName, authHeader) {
500
+ this.logOperation('uploadTaskAttachmentFromUrl', { taskId, fileUrl, fileName });
501
+ try {
502
+ return await this.makeRequest(async () => {
503
+ // Import required modules
504
+ const axios = (await import('axios')).default;
505
+ const FormData = (await import('form-data')).default;
506
+ // Download the file from the URL
507
+ const headers = {};
508
+ if (authHeader) {
509
+ headers['Authorization'] = authHeader;
510
+ }
511
+ const response = await axios.get(fileUrl, {
512
+ responseType: 'arraybuffer',
513
+ headers
514
+ });
515
+ // Extract filename from URL if not provided
516
+ const actualFileName = fileName || fileUrl.split('/').pop() || 'downloaded-file';
517
+ // Create FormData for multipart/form-data upload
518
+ const formData = new FormData();
519
+ // Add the file to the form data
520
+ formData.append('attachment', Buffer.from(response.data), {
521
+ filename: actualFileName,
522
+ contentType: 'application/octet-stream'
523
+ });
524
+ // Upload the file to ClickUp
525
+ const uploadResponse = await this.client.post(`/task/${taskId}/attachment`, formData, {
526
+ headers: {
527
+ ...formData.getHeaders(),
528
+ 'Authorization': this.apiKey
529
+ }
530
+ });
531
+ return uploadResponse.data;
532
+ });
533
+ }
534
+ catch (error) {
535
+ throw this.handleError(error, `Failed to upload attachment from URL to task ${taskId}`);
536
+ }
537
+ }
397
538
  }
@@ -0,0 +1,354 @@
1
+ /**
2
+ * ClickUp MCP Task Attachment Tool
3
+ *
4
+ * This module implements a tool for attaching files to ClickUp tasks
5
+ * with automatic method selection based on file source and size.
6
+ */
7
+ import { taskService } from '../../services/shared.js';
8
+ import { validateTaskIdentification, resolveTaskIdWithValidation } from './utilities.js';
9
+ import { sponsorService } from '../../utils/sponsor-service.js';
10
+ // Session storage for chunked uploads (in-memory for demonstration)
11
+ // In production, this should use a more persistent store
12
+ const chunkSessions = new Map();
13
+ // Clean up expired sessions periodically
14
+ setInterval(() => {
15
+ const now = Date.now();
16
+ const expired = 24 * 60 * 60 * 1000; // 24 hours
17
+ for (const [token, session] of chunkSessions.entries()) {
18
+ if (now - session.timestamp > expired) {
19
+ chunkSessions.delete(token);
20
+ console.log(`Cleaned up expired upload session: ${token}`);
21
+ }
22
+ }
23
+ }, 3600 * 1000); // Check every hour
24
+ /**
25
+ * Single unified tool for attaching files to ClickUp tasks
26
+ */
27
+ export const attachTaskFileTool = {
28
+ name: "attach_task_file",
29
+ description: `Purpose: Attaches a file to a ClickUp task.
30
+
31
+ Valid Usage:
32
+ 1. Upload from base64: Provide file_data + file_name
33
+ 2. Upload from URL or local file: Provide file_url + optional file_name
34
+ - For web URLs: Use http:// or https:// URLs
35
+ - For local files: Use absolute file paths (starting with / or drive letter)
36
+ 3. For large files, advanced options are available via chunk_* parameters
37
+
38
+ Requirements:
39
+ - EITHER taskId OR (taskName + listName) is REQUIRED
40
+ - EITHER file_data OR file_url is REQUIRED
41
+
42
+ Notes:
43
+ - The system automatically selects the best upload method based on file size and source
44
+ - Base64 method has a 10MB size limit due to encoding overhead (file_data parameter)
45
+ - URL method works for files hosted online (file_url parameter with http/https)
46
+ - Local file method works with absolute paths only (file_url parameter with / or drive letter)
47
+ - For large files, the system may use chunked uploading automatically`,
48
+ inputSchema: {
49
+ type: "object",
50
+ properties: {
51
+ taskId: {
52
+ type: "string",
53
+ description: "ID of the task to attach the file to. Works with both regular task IDs (9 characters) and custom IDs with uppercase prefixes (like 'DEV-1234')."
54
+ },
55
+ taskName: {
56
+ type: "string",
57
+ description: "Name of the task to attach the file to. When using this parameter, you MUST also provide listName."
58
+ },
59
+ listName: {
60
+ type: "string",
61
+ description: "Name of the list containing the task. REQUIRED when using taskName."
62
+ },
63
+ file_name: {
64
+ type: "string",
65
+ description: "Name of the file to be attached (include the extension). Required when using file_data."
66
+ },
67
+ file_data: {
68
+ type: "string",
69
+ description: "Base64-encoded content of the file (without the data URL prefix)."
70
+ },
71
+ file_url: {
72
+ type: "string",
73
+ description: "DUAL PURPOSE PARAMETER: Either (1) a web URL starting with http/https to download a file from, OR (2) an absolute local file path starting with / or drive letter. DO NOT use relative paths."
74
+ },
75
+ auth_header: {
76
+ type: "string",
77
+ description: "Optional authorization header to use when downloading from a web URL (ignored for local files)."
78
+ },
79
+ // Advanced parameters for chunked uploads - usually not needed as chunking is automatic
80
+ chunk_index: {
81
+ type: "number",
82
+ description: "Optional: For advanced usage with large file chunking. The 0-based index of this chunk."
83
+ },
84
+ chunk_session: {
85
+ type: "string",
86
+ description: "Optional: For advanced usage with large file chunking. Session identifier from a previous chunk upload."
87
+ },
88
+ chunk_total: {
89
+ type: "number",
90
+ description: "Optional: For advanced usage with large file chunking. Total number of chunks expected."
91
+ },
92
+ chunk_is_last: {
93
+ type: "boolean",
94
+ description: "Optional: For advanced usage with large file chunking. Whether this is the final chunk."
95
+ }
96
+ },
97
+ required: [] // Will validate based on context in the handler
98
+ }
99
+ };
100
+ /**
101
+ * Handler function for the attachTaskFileTool
102
+ */
103
+ async function attachTaskFileHandler(params) {
104
+ // Extract common parameters
105
+ const { taskId, taskName, listName, file_name, file_data, file_url, auth_header, chunk_total, chunk_size, chunk_index, session_id } = params;
106
+ // Validate task identification
107
+ validateTaskIdentification(params);
108
+ // Validate file source - either file_data or file_url must be provided
109
+ if (!file_data && !file_url && !session_id) {
110
+ throw new Error("Either file_data, file_url, or session_id must be provided");
111
+ }
112
+ // Resolve task ID
113
+ const resolvedTaskId = await resolveTaskIdWithValidation(taskId, taskName, listName);
114
+ try {
115
+ // CASE 1: Chunked upload continuation
116
+ if (session_id) {
117
+ return await handleChunkUpload(resolvedTaskId, session_id, chunk_index, file_data, chunk_total === chunk_index + 1);
118
+ }
119
+ // CASE 2: URL-based upload or local file path
120
+ if (file_url) {
121
+ // Check if it's a local file path
122
+ console.log(`Checking if path is local: ${file_url}`);
123
+ if (file_url.startsWith('/') || /^[A-Za-z]:\\/.test(file_url)) {
124
+ console.log(`Detected as local path, proceeding to handle: ${file_url}`);
125
+ return await handleLocalFileUpload(resolvedTaskId, file_url, file_name);
126
+ }
127
+ else if (file_url.startsWith('http://') || file_url.startsWith('https://')) {
128
+ console.log(`Detected as URL, proceeding with URL upload: ${file_url}`);
129
+ return await handleUrlUpload(resolvedTaskId, file_url, file_name, auth_header);
130
+ }
131
+ else {
132
+ throw new Error(`Invalid file_url format: "${file_url}". The file_url parameter must be either an absolute file path (starting with / or drive letter) or a web URL (starting with http:// or https://)`);
133
+ }
134
+ }
135
+ // CASE 3: Base64 upload (with automatic chunking for large files)
136
+ if (file_data) {
137
+ if (!file_name) {
138
+ throw new Error("file_name is required when using file_data");
139
+ }
140
+ // Check if we need to use chunking (file > 10MB)
141
+ const fileBuffer = Buffer.from(file_data, 'base64');
142
+ const fileSize = fileBuffer.length;
143
+ if (fileSize > 10 * 1024 * 1024) {
144
+ // For large files, start chunked upload process
145
+ return await startChunkedUpload(resolvedTaskId, file_name, fileBuffer);
146
+ }
147
+ else {
148
+ // For small files, upload directly
149
+ return await handleDirectUpload(resolvedTaskId, file_name, fileBuffer);
150
+ }
151
+ }
152
+ throw new Error("Invalid parameters: Unable to determine upload method");
153
+ }
154
+ catch (error) {
155
+ console.error(`Error attaching file to task:`, error);
156
+ throw error;
157
+ }
158
+ }
159
+ /**
160
+ * Handle direct upload for small files
161
+ */
162
+ async function handleDirectUpload(taskId, fileName, fileBuffer) {
163
+ try {
164
+ // Call service method
165
+ const result = await taskService.uploadTaskAttachment(taskId, fileBuffer, fileName);
166
+ return {
167
+ success: true,
168
+ message: `File "${fileName}" successfully attached to task ${taskId}`,
169
+ attachment: result
170
+ };
171
+ }
172
+ catch (error) {
173
+ throw new Error(`Failed to upload file: ${error.message}`);
174
+ }
175
+ }
176
+ /**
177
+ * Handle URL-based upload
178
+ */
179
+ async function handleUrlUpload(taskId, fileUrl, fileName, authHeader) {
180
+ try {
181
+ // Extract filename from URL if not provided
182
+ const extractedFileName = fileName || new URL(fileUrl).pathname.split('/').pop() || 'downloaded-file';
183
+ // Call service method
184
+ const result = await taskService.uploadTaskAttachmentFromUrl(taskId, fileUrl, extractedFileName, authHeader);
185
+ return {
186
+ success: true,
187
+ message: `File from "${fileUrl}" successfully attached to task ${taskId}`,
188
+ attachment: result
189
+ };
190
+ }
191
+ catch (error) {
192
+ if (error.message === 'Invalid URL') {
193
+ throw new Error(`Failed to upload file from URL: Invalid URL format. The file_url parameter must be a valid web URL starting with http:// or https://`);
194
+ }
195
+ throw new Error(`Failed to upload file from URL: ${error.message}`);
196
+ }
197
+ }
198
+ /**
199
+ * Start a chunked upload process for large files
200
+ */
201
+ async function startChunkedUpload(taskId, fileName, fileBuffer) {
202
+ // Generate a session token
203
+ const sessionToken = `chunk_session_${Date.now()}_${Math.random().toString(36).substring(2, 15)}`;
204
+ // Store the file in chunks (for demonstration - in production would store chunk info only)
205
+ // Split the file into chunks for storage
206
+ const chunkSize = 5 * 1024 * 1024; // 5MB chunks
207
+ const chunksMap = new Map();
208
+ for (let i = 0; i < fileBuffer.length; i += chunkSize) {
209
+ const chunk = fileBuffer.slice(i, i + chunkSize);
210
+ chunksMap.set(Math.floor(i / chunkSize), chunk);
211
+ }
212
+ // Create a new session
213
+ chunkSessions.set(sessionToken, {
214
+ taskId,
215
+ fileName,
216
+ fileSize: fileBuffer.length,
217
+ chunks: chunksMap,
218
+ timestamp: Date.now()
219
+ });
220
+ // Return initial chunk
221
+ return {
222
+ success: true,
223
+ message: `Large file detected. Chunked upload initialized for "${fileName}" (${fileBuffer.length} bytes)`,
224
+ chunk_session: sessionToken,
225
+ chunks_total: chunksMap.size,
226
+ chunk_uploaded: 1,
227
+ attachment: null,
228
+ details: {
229
+ taskId,
230
+ fileName,
231
+ fileSize: fileBuffer.length,
232
+ chunkCount: chunksMap.size,
233
+ progress: Math.round((1 / chunksMap.size) * 100)
234
+ }
235
+ };
236
+ }
237
+ /**
238
+ * Handle chunk upload as part of a multi-chunk process
239
+ */
240
+ async function handleChunkUpload(taskId, sessionToken, chunkIndex, chunkData, isLastChunk) {
241
+ // Verify session exists
242
+ const session = chunkSessions.get(sessionToken);
243
+ if (!session) {
244
+ throw new Error("Upload session not found or expired");
245
+ }
246
+ // If this is the last chunk or all chunks are uploaded, finalize the upload
247
+ if (isLastChunk || (session.chunks.size === 1 && chunkIndex === undefined)) {
248
+ // Combine all chunks
249
+ const fileData = Buffer.allocUnsafe(session.fileSize);
250
+ let offset = 0;
251
+ // Sort chunks by index
252
+ const sortedChunks = Array.from(session.chunks.entries())
253
+ .sort((a, b) => a[0] - b[0]);
254
+ for (const [index, chunk] of sortedChunks) {
255
+ chunk.copy(fileData, offset);
256
+ offset += chunk.length;
257
+ }
258
+ try {
259
+ // Call service method
260
+ const result = await taskService.uploadTaskAttachment(session.taskId, fileData, session.fileName);
261
+ // Clean up the session
262
+ chunkSessions.delete(sessionToken);
263
+ return {
264
+ success: true,
265
+ message: `File "${session.fileName}" successfully attached to task ${session.taskId}`,
266
+ attachment: result
267
+ };
268
+ }
269
+ catch (error) {
270
+ throw new Error(`Failed to upload file: ${error.message}`);
271
+ }
272
+ }
273
+ // Otherwise handle the current chunk
274
+ if (chunkIndex === undefined || chunkData === undefined) {
275
+ throw new Error("chunk_index and chunk_data are required for chunk uploads");
276
+ }
277
+ // Store the chunk
278
+ // (In a real implementation, we'd append to a temp file or storage)
279
+ session.chunks.delete(chunkIndex); // Remove the chunk if it exists
280
+ session.chunks.set(chunkIndex, Buffer.from(chunkData, 'base64'));
281
+ return {
282
+ success: true,
283
+ message: `Chunk ${chunkIndex + 1}/${session.chunks.size} received`,
284
+ chunk_session: sessionToken,
285
+ chunks_remaining: session.chunks.size - chunkIndex - 1,
286
+ details: {
287
+ taskId: session.taskId,
288
+ fileName: session.fileName,
289
+ chunksReceived: chunkIndex + 1,
290
+ progress: Math.round(((chunkIndex + 1) / session.chunks.size) * 100)
291
+ }
292
+ };
293
+ }
294
+ /**
295
+ * Handle local file path upload
296
+ */
297
+ async function handleLocalFileUpload(taskId, filePath, fileName) {
298
+ try {
299
+ // Import fs and path modules
300
+ const fs = await import('fs');
301
+ const path = await import('path');
302
+ console.log(`Processing absolute file path: ${filePath}`);
303
+ // Normalize the path to prevent directory traversal attacks
304
+ const normalizedPath = path.normalize(filePath);
305
+ // Check if file exists
306
+ if (!fs.existsSync(normalizedPath)) {
307
+ throw new Error(`Local file not found: ${normalizedPath}`);
308
+ }
309
+ // Validate file stats
310
+ const stats = fs.statSync(normalizedPath);
311
+ if (!stats.isFile()) {
312
+ throw new Error(`Path is not a file: ${normalizedPath}`);
313
+ }
314
+ // Get file name if not provided
315
+ const extractedFileName = fileName || path.basename(normalizedPath);
316
+ // Read file
317
+ const fileBuffer = fs.readFileSync(normalizedPath);
318
+ const fileSize = fileBuffer.length;
319
+ console.log(`Successfully read file: ${extractedFileName} (${fileSize} bytes)`);
320
+ // Choose upload method based on file size
321
+ if (fileSize > 10 * 1024 * 1024) {
322
+ // For large files, start chunked upload process
323
+ return await startChunkedUpload(taskId, extractedFileName, fileBuffer);
324
+ }
325
+ else {
326
+ // For small files, upload directly
327
+ return await handleDirectUpload(taskId, extractedFileName, fileBuffer);
328
+ }
329
+ }
330
+ catch (error) {
331
+ if (error.message.includes('ENOENT')) {
332
+ throw new Error(`Failed to upload local file: Local file not found: ${filePath}. Make sure the file exists and the path is absolute.`);
333
+ }
334
+ else if (error.message.includes('EACCES')) {
335
+ throw new Error(`Failed to upload local file: Permission denied accessing: ${filePath}. Check file permissions.`);
336
+ }
337
+ throw new Error(`Failed to upload local file: ${error.message}`);
338
+ }
339
+ }
340
+ /**
341
+ * Creates a wrapped handler function with standard error handling and response formatting
342
+ */
343
+ function createHandlerWrapper(handler, formatResponse = (result) => result) {
344
+ return async (parameters) => {
345
+ try {
346
+ const result = await handler(parameters);
347
+ return sponsorService.createResponse(formatResponse(result), true);
348
+ }
349
+ catch (error) {
350
+ return sponsorService.createErrorResponse(error, parameters);
351
+ }
352
+ };
353
+ }
354
+ export const handleAttachTaskFile = createHandlerWrapper(attachTaskFileHandler);