bitrix24-tasks-mcp-server 1.2.0 → 1.4.0

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.
@@ -1,6 +1,8 @@
1
1
  import fetch from 'node-fetch';
2
2
  import { z } from 'zod';
3
3
  import { collectDiskFileIdsFromTask, guessMimeType, isImageFile, parseWebdavFileToken, readLocalFileAsBase64, saveBufferToDirectory, toWebdavFileToken } from './taskFiles.js';
4
+ import { extractCommentAttachments, normalizeTaskComment } from './taskComments.js';
5
+ import { buildDialogId, indexChatFiles, isImageChatFile, normalizeChatFile, normalizeChatMessage, resolveMessageAttachments } from './taskChat.js';
4
6
  const BITRIX24_WEBHOOK_URL = process.env.BITRIX24_WEBHOOK_URL ||
5
7
  'https://sviluppofranchising.bitrix24.it/rest/27/wwugdez6m774803q/';
6
8
  const BitrixResponseSchema = z.object({
@@ -28,6 +30,35 @@ export class Bitrix24Client {
28
30
  const match = this.baseUrl.match(/^(https?:\/\/[^/]+)/i);
29
31
  return match?.[1] ?? '';
30
32
  }
33
+ buildMethodUrl(method, useApiV3 = false) {
34
+ let base = this.baseUrl;
35
+ if (useApiV3) {
36
+ if (!/\/rest\/api\//i.test(base)) {
37
+ base = base.replace(/\/rest\//i, '/rest/api/');
38
+ }
39
+ }
40
+ else {
41
+ base = base.replace(/\/rest\/api\//i, '/rest/');
42
+ }
43
+ return `${base}/${method}`;
44
+ }
45
+ extractApiError(data) {
46
+ const error = data.error;
47
+ if (!error) {
48
+ return null;
49
+ }
50
+ if (typeof error === 'string') {
51
+ const description = data.error_description;
52
+ return description ? `${error} - ${String(description)}` : error;
53
+ }
54
+ if (typeof error === 'object' && error !== null) {
55
+ const record = error;
56
+ const code = record.code ?? record.error;
57
+ const message = record.message ?? record.error_description;
58
+ return `${String(code ?? 'ERROR')}: ${String(message ?? 'Unknown error')}`;
59
+ }
60
+ return 'Unknown Bitrix24 API error';
61
+ }
31
62
  async enforceRateLimit() {
32
63
  const now = Date.now();
33
64
  const timeSinceLastRequest = now - this.lastRequestTime;
@@ -36,9 +67,12 @@ export class Bitrix24Client {
36
67
  }
37
68
  this.lastRequestTime = Date.now();
38
69
  }
39
- async makeRequest(method, params = {}) {
70
+ async callRest(method, params = {}, options = {}) {
71
+ return this.makeRequest(method, params, options);
72
+ }
73
+ async makeRequest(method, params = {}, options = {}) {
40
74
  await this.enforceRateLimit();
41
- const url = `${this.baseUrl}/${method}`;
75
+ const url = this.buildMethodUrl(method, options.useApiV3 === true);
42
76
  try {
43
77
  let response;
44
78
  if (Object.keys(params).length === 0) {
@@ -68,11 +102,15 @@ export class Bitrix24Client {
68
102
  console.error(`HTTP Error ${response.status}:`, errorText);
69
103
  throw new Error(`HTTP ${response.status}: ${response.statusText}`);
70
104
  }
71
- const data = await response.json();
72
- const parsed = BitrixResponseSchema.parse(data);
73
- if (parsed.error) {
74
- throw new Error(`Bitrix24 API Error: ${parsed.error.error} - ${parsed.error.error_description}`);
105
+ const data = (await response.json());
106
+ const apiError = this.extractApiError(data);
107
+ if (apiError) {
108
+ throw new Error(`Bitrix24 API Error: ${apiError}`);
75
109
  }
110
+ if (!('result' in data)) {
111
+ return data;
112
+ }
113
+ const parsed = BitrixResponseSchema.parse(data);
76
114
  return parsed.result;
77
115
  }
78
116
  catch (error) {
@@ -573,6 +611,449 @@ export class Bitrix24Client {
573
611
  }
574
612
  return { taskId, diskFileIds: uniqueIds, files, errors };
575
613
  }
614
+ async resolveTaskChat(taskId) {
615
+ try {
616
+ const legacy = await this.makeRequest('tasks.task.get', {
617
+ taskId,
618
+ select: ['ID', 'CHAT_ID']
619
+ });
620
+ const chatId = legacy?.task?.CHAT_ID ?? legacy?.task?.chatId;
621
+ if (chatId !== undefined && chatId !== null && chatId !== '') {
622
+ return { chatId: String(chatId), dialogId: buildDialogId(chatId) };
623
+ }
624
+ }
625
+ catch (error) {
626
+ console.error(`tasks.task.get CHAT_ID failed for task ${taskId}:`, error);
627
+ }
628
+ try {
629
+ const modern = await this.makeRequest('tasks.task.get', {
630
+ id: Number(taskId),
631
+ select: ['id', 'chat.id']
632
+ }, { useApiV3: true });
633
+ const chatId = modern?.item?.chat?.id ?? modern?.chat?.id;
634
+ if (chatId !== undefined && chatId !== null && chatId !== '') {
635
+ return { chatId: String(chatId), dialogId: buildDialogId(chatId) };
636
+ }
637
+ }
638
+ catch (error) {
639
+ console.error(`tasks.task.get (v3) chat.id failed for task ${taskId}:`, error);
640
+ }
641
+ return null;
642
+ }
643
+ async listTaskComments(taskId, options = {}) {
644
+ const payload = {
645
+ TASKID: Number(taskId)
646
+ };
647
+ if (options.order && Object.keys(options.order).length > 0) {
648
+ payload.ORDER = options.order;
649
+ }
650
+ if (options.filter && Object.keys(options.filter).length > 0) {
651
+ payload.FILTER = options.filter;
652
+ }
653
+ const result = await this.makeRequest('task.commentitem.getlist', payload);
654
+ if (!Array.isArray(result)) {
655
+ return [];
656
+ }
657
+ return result
658
+ .map((item) => normalizeTaskComment(item))
659
+ .filter((comment) => comment.id);
660
+ }
661
+ async getTaskComment(taskId, commentId) {
662
+ const result = await this.makeRequest('task.commentitem.get', {
663
+ TASKID: Number(taskId),
664
+ ITEMID: Number(commentId)
665
+ });
666
+ if (!result || typeof result !== 'object') {
667
+ return null;
668
+ }
669
+ const comment = normalizeTaskComment(result);
670
+ return comment.id ? comment : null;
671
+ }
672
+ async fetchTaskCommentRawItems(taskId, options) {
673
+ const payload = {
674
+ TASKID: Number(taskId)
675
+ };
676
+ if (options.order && Object.keys(options.order).length > 0) {
677
+ payload.ORDER = options.order;
678
+ }
679
+ if (options.filter && Object.keys(options.filter).length > 0) {
680
+ payload.FILTER = options.filter;
681
+ }
682
+ const result = await this.makeRequest('task.commentitem.getlist', payload);
683
+ if (!Array.isArray(result)) {
684
+ return [];
685
+ }
686
+ return result;
687
+ }
688
+ async downloadChatFileAttachment(commentId, file, options) {
689
+ const attached = {
690
+ attachmentId: file.attachmentId,
691
+ fileId: file.fileId,
692
+ name: file.name,
693
+ size: file.size,
694
+ downloadUrl: file.downloadUrl,
695
+ viewUrl: file.viewUrl
696
+ };
697
+ const downloaded = await this.downloadTaskAttachedFile(attached, options);
698
+ return { ...downloaded, commentId };
699
+ }
700
+ async getTaskCommentsFromChat(taskId, chat, options) {
701
+ const payload = {
702
+ DIALOG_ID: chat.dialogId,
703
+ LIMIT: Math.min(Math.max(options.limit ?? 50, 1), 50)
704
+ };
705
+ if (options.lastId !== undefined) {
706
+ payload.LAST_ID = options.lastId;
707
+ }
708
+ if (options.firstId !== undefined) {
709
+ payload.FIRST_ID = options.firstId;
710
+ }
711
+ const result = await this.makeRequest('im.dialog.messages.get', payload);
712
+ const messagesRaw = Array.isArray(result?.messages) ? result.messages : [];
713
+ const filesRaw = Array.isArray(result?.files) ? result.files : [];
714
+ const files = filesRaw
715
+ .map((item) => normalizeChatFile(item))
716
+ .filter((item) => item !== null);
717
+ const filesById = indexChatFiles(files);
718
+ const includeAttachments = options.includeAttachments !== false;
719
+ const requestedCommentIds = options.commentIds?.map((id) => id.trim()).filter(Boolean) ?? [];
720
+ const requestedAttachmentIds = options.attachmentIds?.map((id) => id.trim()).filter(Boolean) ?? [];
721
+ const comments = [];
722
+ const errors = [];
723
+ for (const rawMessage of messagesRaw) {
724
+ const message = normalizeChatMessage(rawMessage);
725
+ if (!message) {
726
+ continue;
727
+ }
728
+ if (!options.includeSystemMessages && message.isSystem) {
729
+ continue;
730
+ }
731
+ if (requestedCommentIds.length > 0 && !requestedCommentIds.includes(message.id)) {
732
+ continue;
733
+ }
734
+ const attachments = resolveMessageAttachments(message, filesById);
735
+ const selectedAttachments = this.selectCommentAttachments(attachments, requestedAttachmentIds);
736
+ const downloadedFiles = [];
737
+ if (includeAttachments) {
738
+ for (const attachment of selectedAttachments) {
739
+ const chatFile = filesById.get(attachment.fileId ?? attachment.attachmentId);
740
+ if (options.imagesOnly === true && chatFile && !isImageChatFile(chatFile)) {
741
+ continue;
742
+ }
743
+ try {
744
+ downloadedFiles.push(await this.downloadChatFileAttachment(message.id, attachment, options));
745
+ }
746
+ catch (error) {
747
+ const errMessage = error instanceof Error ? error.message : String(error);
748
+ errors.push(`message ${message.id}, file ${attachment.attachmentId}: ${errMessage}`);
749
+ downloadedFiles.push({
750
+ diskFileId: attachment.fileId ?? attachment.attachmentId,
751
+ attachmentId: attachment.attachmentId,
752
+ fileId: attachment.fileId,
753
+ fileName: attachment.name,
754
+ mimeType: guessMimeType(attachment.name),
755
+ isImage: chatFile ? isImageChatFile(chatFile) : isImageFile(attachment.name),
756
+ error: errMessage,
757
+ commentId: message.id
758
+ });
759
+ }
760
+ }
761
+ }
762
+ comments.push({
763
+ id: message.id,
764
+ authorId: message.authorId !== '0' ? message.authorId : undefined,
765
+ postDate: message.date,
766
+ postMessage: message.text,
767
+ postMessageHtml: null,
768
+ attachments: selectedAttachments,
769
+ downloadedFiles
770
+ });
771
+ }
772
+ const orderDirection = Object.values(options.order ?? { POST_DATE: 'asc' })[0]?.toLowerCase();
773
+ if (orderDirection === 'desc') {
774
+ comments.reverse();
775
+ }
776
+ return { comments, errors };
777
+ }
778
+ async getTaskCommentsLegacy(taskId, options) {
779
+ const includeAttachments = options.includeAttachments !== false;
780
+ const requestedCommentIds = options.commentIds?.map((id) => id.trim()).filter(Boolean) ?? [];
781
+ const requestedAttachmentIds = options.attachmentIds?.map((id) => id.trim()).filter(Boolean) ?? [];
782
+ const rawItems = await this.fetchTaskCommentRawItems(taskId, options);
783
+ const filteredItems = requestedCommentIds.length > 0
784
+ ? rawItems.filter((item) => requestedCommentIds.includes(String(item.ID ?? item.id ?? '')))
785
+ : rawItems;
786
+ const comments = [];
787
+ const errors = [];
788
+ for (const rawItem of filteredItems) {
789
+ const comment = normalizeTaskComment(rawItem);
790
+ if (!comment.id) {
791
+ continue;
792
+ }
793
+ const attachments = extractCommentAttachments(rawItem);
794
+ const selectedAttachments = this.selectCommentAttachments(attachments, requestedAttachmentIds);
795
+ const downloadedFiles = [];
796
+ if (includeAttachments) {
797
+ for (const attachment of selectedAttachments) {
798
+ try {
799
+ if (options.imagesOnly === true && !isImageFile(attachment.name)) {
800
+ continue;
801
+ }
802
+ downloadedFiles.push(await this.downloadCommentAttachment(comment.id, attachment, options));
803
+ }
804
+ catch (error) {
805
+ const message = error instanceof Error ? error.message : String(error);
806
+ errors.push(`comment ${comment.id}, attachment ${attachment.attachmentId}: ${message}`);
807
+ downloadedFiles.push({
808
+ diskFileId: attachment.fileId
809
+ ? toWebdavFileToken(attachment.fileId)
810
+ : attachment.attachmentId,
811
+ attachmentId: attachment.attachmentId,
812
+ fileId: attachment.fileId,
813
+ fileName: attachment.name,
814
+ mimeType: guessMimeType(attachment.name),
815
+ isImage: isImageFile(attachment.name),
816
+ error: message,
817
+ commentId: comment.id
818
+ });
819
+ }
820
+ }
821
+ }
822
+ comments.push({
823
+ ...comment,
824
+ attachments: selectedAttachments,
825
+ downloadedFiles
826
+ });
827
+ }
828
+ return { comments, errors };
829
+ }
830
+ async sendTaskCommentViaChat(taskId, chat, options) {
831
+ const text = (options.text ?? '').trim();
832
+ const filePaths = options.filePaths ?? [];
833
+ const uploadedDiskFileIds = [];
834
+ for (const filePath of filePaths) {
835
+ const { fileName, base64 } = await readLocalFileAsBase64(filePath);
836
+ const uploaded = await this.uploadFileToDisk({
837
+ folderId: options.diskFolderId,
838
+ fileName,
839
+ base64Content: base64
840
+ });
841
+ uploadedDiskFileIds.push(uploaded.diskObjectId);
842
+ }
843
+ if (uploadedDiskFileIds.length > 0) {
844
+ const commitResult = await this.makeRequest('im.disk.file.commit', {
845
+ CHAT_ID: Number(chat.chatId),
846
+ FILE_ID: uploadedDiskFileIds.map((id) => Number(id)),
847
+ MESSAGE: text || undefined
848
+ });
849
+ const messageId = commitResult?.MESSAGE_ID !== undefined
850
+ ? String(commitResult.MESSAGE_ID)
851
+ : commitResult?.messageId !== undefined
852
+ ? String(commitResult.messageId)
853
+ : undefined;
854
+ return { messageId, uploadedDiskFileIds };
855
+ }
856
+ if (!text) {
857
+ throw new Error('Comment text or filePaths is required');
858
+ }
859
+ try {
860
+ await this.makeRequest('tasks.task.chat.message.send', {
861
+ fields: {
862
+ taskId: Number(taskId),
863
+ text
864
+ }
865
+ }, { useApiV3: true });
866
+ return { uploadedDiskFileIds };
867
+ }
868
+ catch (v3Error) {
869
+ console.error('tasks.task.chat.message.send failed, fallback to im.message.add:', v3Error);
870
+ }
871
+ const messageId = await this.makeRequest('im.message.add', {
872
+ DIALOG_ID: chat.dialogId,
873
+ MESSAGE: text,
874
+ SYSTEM: 'N'
875
+ });
876
+ return {
877
+ messageId: messageId !== undefined ? String(messageId) : undefined,
878
+ uploadedDiskFileIds
879
+ };
880
+ }
881
+ async sendTaskCommentLegacy(taskId, options) {
882
+ const text = (options.text ?? '').trim();
883
+ const filePaths = options.filePaths ?? [];
884
+ const uploadedDiskFileIds = [];
885
+ const forumDocs = [];
886
+ for (const filePath of filePaths) {
887
+ const { fileName, base64 } = await readLocalFileAsBase64(filePath);
888
+ const uploaded = await this.uploadFileToDisk({
889
+ folderId: options.diskFolderId,
890
+ fileName,
891
+ base64Content: base64
892
+ });
893
+ uploadedDiskFileIds.push(uploaded.diskObjectId);
894
+ forumDocs.push(toWebdavFileToken(uploaded.diskObjectId));
895
+ }
896
+ if (!text && forumDocs.length === 0) {
897
+ throw new Error('Comment text or filePaths is required');
898
+ }
899
+ const fields = {
900
+ POST_MESSAGE: text || (forumDocs.length > 0 ? ' ' : '')
901
+ };
902
+ if (options.authorId) {
903
+ fields.AUTHOR_ID = Number(options.authorId);
904
+ }
905
+ if (forumDocs.length > 0) {
906
+ fields.UF_FORUM_MESSAGE_DOC = forumDocs;
907
+ }
908
+ const commentId = await this.makeRequest('task.commentitem.add', {
909
+ TASKID: Number(taskId),
910
+ FIELDS: fields
911
+ });
912
+ return {
913
+ commentId: String(commentId),
914
+ uploadedDiskFileIds
915
+ };
916
+ }
917
+ async sendTaskComment(taskId, options) {
918
+ const preferApi = options.preferApi ?? 'auto';
919
+ const errors = [];
920
+ let chat = null;
921
+ if (preferApi !== 'legacy') {
922
+ chat = await this.resolveTaskChat(taskId);
923
+ }
924
+ if (chat && preferApi !== 'legacy') {
925
+ try {
926
+ const sent = await this.sendTaskCommentViaChat(taskId, chat, options);
927
+ return {
928
+ success: true,
929
+ apiMode: 'chat',
930
+ taskId,
931
+ chatId: chat.chatId,
932
+ messageId: sent.messageId,
933
+ uploadedDiskFileIds: sent.uploadedDiskFileIds,
934
+ errors
935
+ };
936
+ }
937
+ catch (error) {
938
+ const message = error instanceof Error ? error.message : String(error);
939
+ errors.push(`chat API: ${message}`);
940
+ if (preferApi === 'chat') {
941
+ return {
942
+ success: false,
943
+ apiMode: 'chat',
944
+ taskId,
945
+ chatId: chat.chatId,
946
+ uploadedDiskFileIds: [],
947
+ errors
948
+ };
949
+ }
950
+ }
951
+ }
952
+ try {
953
+ const sent = await this.sendTaskCommentLegacy(taskId, options);
954
+ return {
955
+ success: true,
956
+ apiMode: 'legacy',
957
+ taskId,
958
+ commentId: sent.commentId,
959
+ uploadedDiskFileIds: sent.uploadedDiskFileIds,
960
+ errors
961
+ };
962
+ }
963
+ catch (error) {
964
+ const message = error instanceof Error ? error.message : String(error);
965
+ errors.push(`legacy API: ${message}`);
966
+ return {
967
+ success: false,
968
+ apiMode: 'legacy',
969
+ taskId,
970
+ chatId: chat?.chatId,
971
+ uploadedDiskFileIds: [],
972
+ errors
973
+ };
974
+ }
975
+ }
976
+ matchesRequestedAttachmentId(attachment, requestedId) {
977
+ const normalized = requestedId.replace(/^n/i, '').trim();
978
+ if (!normalized) {
979
+ return false;
980
+ }
981
+ return (attachment.attachmentId === normalized ||
982
+ attachment.fileId === normalized ||
983
+ (requestedId.startsWith('n') && attachment.fileId === normalized));
984
+ }
985
+ selectCommentAttachments(attachments, requestedIds) {
986
+ if (requestedIds.length === 0) {
987
+ return attachments;
988
+ }
989
+ const selected = [];
990
+ for (const requestedId of requestedIds) {
991
+ const match = attachments.find((file) => this.matchesRequestedAttachmentId(file, requestedId));
992
+ if (match && !selected.some((item) => item.attachmentId === match.attachmentId)) {
993
+ selected.push(match);
994
+ }
995
+ }
996
+ return selected;
997
+ }
998
+ async downloadCommentAttachment(commentId, attached, options) {
999
+ const taskAttached = {
1000
+ attachmentId: attached.attachmentId,
1001
+ fileId: attached.fileId,
1002
+ name: attached.name,
1003
+ size: attached.size,
1004
+ downloadUrl: attached.downloadUrl,
1005
+ viewUrl: attached.viewUrl
1006
+ };
1007
+ const downloaded = await this.downloadTaskAttachedFile(taskAttached, options);
1008
+ return {
1009
+ ...downloaded,
1010
+ commentId
1011
+ };
1012
+ }
1013
+ async getTaskComments(taskId, options = {}) {
1014
+ const preferApi = options.preferApi ?? 'auto';
1015
+ const errors = [];
1016
+ let chat = null;
1017
+ if (preferApi !== 'legacy') {
1018
+ chat = await this.resolveTaskChat(taskId);
1019
+ }
1020
+ if (chat && preferApi !== 'legacy') {
1021
+ try {
1022
+ const chatResult = await this.getTaskCommentsFromChat(taskId, chat, options);
1023
+ return {
1024
+ taskId,
1025
+ apiMode: 'chat',
1026
+ chatId: chat.chatId,
1027
+ dialogId: chat.dialogId,
1028
+ comments: chatResult.comments,
1029
+ errors: [...errors, ...chatResult.errors]
1030
+ };
1031
+ }
1032
+ catch (error) {
1033
+ const message = error instanceof Error ? error.message : String(error);
1034
+ errors.push(`chat API: ${message}`);
1035
+ if (preferApi === 'chat') {
1036
+ return {
1037
+ taskId,
1038
+ apiMode: 'chat',
1039
+ chatId: chat.chatId,
1040
+ dialogId: chat.dialogId,
1041
+ comments: [],
1042
+ errors
1043
+ };
1044
+ }
1045
+ }
1046
+ }
1047
+ const legacyResult = await this.getTaskCommentsLegacy(taskId, options);
1048
+ return {
1049
+ taskId,
1050
+ apiMode: 'legacy',
1051
+ chatId: chat?.chatId,
1052
+ dialogId: chat?.dialogId,
1053
+ comments: legacyResult.comments,
1054
+ errors: [...errors, ...legacyResult.errors]
1055
+ };
1056
+ }
576
1057
  // Project Methods (Bitrix24 workgroups)
577
1058
  async createProject(project) {
578
1059
  const result = await this.makeRequest('sonet_group.create', { fields: project });
@@ -735,6 +1216,7 @@ export class Bitrix24Client {
735
1216
  tasks_access: false,
736
1217
  projects_access: false,
737
1218
  disk_access: false,
1219
+ chat_access: false,
738
1220
  error_details: []
739
1221
  };
740
1222
  try {
@@ -757,6 +1239,9 @@ export class Bitrix24Client {
757
1239
  if (scopes.includes('disk')) {
758
1240
  results.disk_access = true;
759
1241
  }
1242
+ if (scopes.includes('im')) {
1243
+ results.chat_access = true;
1244
+ }
760
1245
  try {
761
1246
  await this.getLatestTasks(1);
762
1247
  results.tasks_access = true;
@@ -782,6 +1267,25 @@ export class Bitrix24Client {
782
1267
  results.error_details.push(`disk access failed: ${error instanceof Error ? error.message : String(error)}`);
783
1268
  }
784
1269
  }
1270
+ try {
1271
+ const latestTasks = await this.getLatestTasks(1);
1272
+ const sampleTaskId = latestTasks[0]?.ID;
1273
+ if (sampleTaskId) {
1274
+ const chat = await this.resolveTaskChat(String(sampleTaskId));
1275
+ if (chat) {
1276
+ await this.makeRequest('im.dialog.messages.get', {
1277
+ DIALOG_ID: chat.dialogId,
1278
+ LIMIT: 1
1279
+ });
1280
+ results.chat_access = true;
1281
+ }
1282
+ }
1283
+ }
1284
+ catch (error) {
1285
+ if (!results.chat_access) {
1286
+ results.error_details.push(`chat access failed: ${error instanceof Error ? error.message : String(error)}`);
1287
+ }
1288
+ }
785
1289
  return results;
786
1290
  }
787
1291
  }