bitrix24-tasks-mcp-server 1.2.0 → 1.5.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}`);
109
+ }
110
+ if (!('result' in data)) {
111
+ return data;
75
112
  }
113
+ const parsed = BitrixResponseSchema.parse(data);
76
114
  return parsed.result;
77
115
  }
78
116
  catch (error) {
@@ -112,6 +150,67 @@ export class Bitrix24Client {
112
150
  const result = await this.makeRequest('tasks.task.get', { taskId: id });
113
151
  return result.task;
114
152
  }
153
+ async getTaskDetails(taskId, options = {}) {
154
+ const task = await this.getTask(taskId);
155
+ const errors = [];
156
+ const includeComments = options.includeComments !== false;
157
+ const includeTaskFiles = options.includeTaskFiles !== false;
158
+ const includeContent = options.includeContent !== false;
159
+ const includeAttachments = options.includeCommentAttachments !== false;
160
+ let comments;
161
+ if (includeComments) {
162
+ try {
163
+ const commentsResult = await this.getTaskComments(taskId, {
164
+ preferApi: options.preferApi,
165
+ limit: options.commentsLimit,
166
+ includeSystemMessages: options.includeSystemMessages,
167
+ commentIds: options.commentIds,
168
+ includeAttachments,
169
+ attachmentIds: options.attachmentIds,
170
+ includeContent,
171
+ imagesOnly: options.imagesOnly,
172
+ saveToDirectory: options.saveToDirectory
173
+ });
174
+ comments = {
175
+ apiMode: commentsResult.apiMode,
176
+ chatId: commentsResult.chatId,
177
+ dialogId: commentsResult.dialogId,
178
+ comments: commentsResult.comments,
179
+ errors: commentsResult.errors
180
+ };
181
+ if (commentsResult.errors.length > 0) {
182
+ errors.push(...commentsResult.errors.map((e) => `comments: ${e}`));
183
+ }
184
+ }
185
+ catch (error) {
186
+ const message = error instanceof Error ? error.message : String(error);
187
+ errors.push(`comments: ${message}`);
188
+ }
189
+ }
190
+ let taskFiles;
191
+ if (includeTaskFiles) {
192
+ try {
193
+ const filesResult = await this.getTaskFiles(taskId, {
194
+ includeContent,
195
+ imagesOnly: options.imagesOnly,
196
+ saveToDirectory: options.saveToDirectory
197
+ });
198
+ taskFiles = {
199
+ diskFileIds: filesResult.diskFileIds,
200
+ files: filesResult.files,
201
+ errors: filesResult.errors
202
+ };
203
+ if (filesResult.errors.length > 0) {
204
+ errors.push(...filesResult.errors.map((e) => `taskFiles: ${e}`));
205
+ }
206
+ }
207
+ catch (error) {
208
+ const message = error instanceof Error ? error.message : String(error);
209
+ errors.push(`taskFiles: ${message}`);
210
+ }
211
+ }
212
+ return { task, comments, taskFiles, errors };
213
+ }
115
214
  async updateTask(id, task) {
116
215
  const result = await this.makeRequest('tasks.task.update', { taskId: id, fields: task });
117
216
  return result === true;
@@ -484,11 +583,26 @@ export class Bitrix24Client {
484
583
  collectTaskDiskFileIds(task) {
485
584
  return collectDiskFileIdsFromTask(task);
486
585
  }
586
+ async resolveAttachmentDownloadUrl(attached) {
587
+ if (attached.fileId) {
588
+ try {
589
+ const diskFile = await this.getDiskFile(parseWebdavFileToken(attached.fileId));
590
+ if (diskFile.downloadUrl) {
591
+ return diskFile.downloadUrl;
592
+ }
593
+ }
594
+ catch (error) {
595
+ console.error(`disk.file.get failed for file ${attached.fileId} (${attached.name}):`, error);
596
+ }
597
+ }
598
+ return attached.downloadUrl;
599
+ }
487
600
  async downloadTaskAttachedFile(attached, options) {
488
601
  const includeContent = options.includeContent !== false;
489
602
  const imagesOnly = options.imagesOnly === true;
490
603
  const isImage = isImageFile(attached.name);
491
604
  const diskFileId = attached.fileId ? toWebdavFileToken(attached.fileId) : attached.attachmentId;
605
+ const downloadUrl = await this.resolveAttachmentDownloadUrl(attached);
492
606
  const entry = {
493
607
  diskFileId,
494
608
  attachmentId: attached.attachmentId,
@@ -497,12 +611,12 @@ export class Bitrix24Client {
497
611
  mimeType: guessMimeType(attached.name),
498
612
  size: attached.size,
499
613
  isImage,
500
- downloadUrl: attached.downloadUrl,
614
+ downloadUrl,
501
615
  viewUrl: attached.viewUrl
502
616
  };
503
- const shouldDownload = includeContent && attached.downloadUrl && (!imagesOnly || isImage);
504
- if (shouldDownload && attached.downloadUrl) {
505
- const buffer = await this.downloadDiskFileContent(attached.downloadUrl);
617
+ const shouldDownload = includeContent && downloadUrl && (!imagesOnly || isImage);
618
+ if (shouldDownload && downloadUrl) {
619
+ const buffer = await this.downloadDiskFileContent(downloadUrl);
506
620
  if (options.saveToDirectory) {
507
621
  entry.savedPath = await saveBufferToDirectory(options.saveToDirectory, attached.name, buffer);
508
622
  }
@@ -573,6 +687,449 @@ export class Bitrix24Client {
573
687
  }
574
688
  return { taskId, diskFileIds: uniqueIds, files, errors };
575
689
  }
690
+ async resolveTaskChat(taskId) {
691
+ try {
692
+ const legacy = await this.makeRequest('tasks.task.get', {
693
+ taskId,
694
+ select: ['ID', 'CHAT_ID']
695
+ });
696
+ const chatId = legacy?.task?.CHAT_ID ?? legacy?.task?.chatId;
697
+ if (chatId !== undefined && chatId !== null && chatId !== '') {
698
+ return { chatId: String(chatId), dialogId: buildDialogId(chatId) };
699
+ }
700
+ }
701
+ catch (error) {
702
+ console.error(`tasks.task.get CHAT_ID failed for task ${taskId}:`, error);
703
+ }
704
+ try {
705
+ const modern = await this.makeRequest('tasks.task.get', {
706
+ id: Number(taskId),
707
+ select: ['id', 'chat.id']
708
+ }, { useApiV3: true });
709
+ const chatId = modern?.item?.chat?.id ?? modern?.chat?.id;
710
+ if (chatId !== undefined && chatId !== null && chatId !== '') {
711
+ return { chatId: String(chatId), dialogId: buildDialogId(chatId) };
712
+ }
713
+ }
714
+ catch (error) {
715
+ console.error(`tasks.task.get (v3) chat.id failed for task ${taskId}:`, error);
716
+ }
717
+ return null;
718
+ }
719
+ async listTaskComments(taskId, options = {}) {
720
+ const payload = {
721
+ TASKID: Number(taskId)
722
+ };
723
+ if (options.order && Object.keys(options.order).length > 0) {
724
+ payload.ORDER = options.order;
725
+ }
726
+ if (options.filter && Object.keys(options.filter).length > 0) {
727
+ payload.FILTER = options.filter;
728
+ }
729
+ const result = await this.makeRequest('task.commentitem.getlist', payload);
730
+ if (!Array.isArray(result)) {
731
+ return [];
732
+ }
733
+ return result
734
+ .map((item) => normalizeTaskComment(item))
735
+ .filter((comment) => comment.id);
736
+ }
737
+ async getTaskComment(taskId, commentId) {
738
+ const result = await this.makeRequest('task.commentitem.get', {
739
+ TASKID: Number(taskId),
740
+ ITEMID: Number(commentId)
741
+ });
742
+ if (!result || typeof result !== 'object') {
743
+ return null;
744
+ }
745
+ const comment = normalizeTaskComment(result);
746
+ return comment.id ? comment : null;
747
+ }
748
+ async fetchTaskCommentRawItems(taskId, options) {
749
+ const payload = {
750
+ TASKID: Number(taskId)
751
+ };
752
+ if (options.order && Object.keys(options.order).length > 0) {
753
+ payload.ORDER = options.order;
754
+ }
755
+ if (options.filter && Object.keys(options.filter).length > 0) {
756
+ payload.FILTER = options.filter;
757
+ }
758
+ const result = await this.makeRequest('task.commentitem.getlist', payload);
759
+ if (!Array.isArray(result)) {
760
+ return [];
761
+ }
762
+ return result;
763
+ }
764
+ async downloadChatFileAttachment(commentId, file, options) {
765
+ const attached = {
766
+ attachmentId: file.attachmentId,
767
+ fileId: file.fileId,
768
+ name: file.name,
769
+ size: file.size,
770
+ downloadUrl: file.downloadUrl,
771
+ viewUrl: file.viewUrl
772
+ };
773
+ const downloaded = await this.downloadTaskAttachedFile(attached, options);
774
+ return { ...downloaded, commentId };
775
+ }
776
+ async getTaskCommentsFromChat(taskId, chat, options) {
777
+ const payload = {
778
+ DIALOG_ID: chat.dialogId,
779
+ LIMIT: Math.min(Math.max(options.limit ?? 50, 1), 50)
780
+ };
781
+ if (options.lastId !== undefined) {
782
+ payload.LAST_ID = options.lastId;
783
+ }
784
+ if (options.firstId !== undefined) {
785
+ payload.FIRST_ID = options.firstId;
786
+ }
787
+ const result = await this.makeRequest('im.dialog.messages.get', payload);
788
+ const messagesRaw = Array.isArray(result?.messages) ? result.messages : [];
789
+ const filesRaw = Array.isArray(result?.files) ? result.files : [];
790
+ const files = filesRaw
791
+ .map((item) => normalizeChatFile(item))
792
+ .filter((item) => item !== null);
793
+ const filesById = indexChatFiles(files);
794
+ const includeAttachments = options.includeAttachments !== false;
795
+ const requestedCommentIds = options.commentIds?.map((id) => id.trim()).filter(Boolean) ?? [];
796
+ const requestedAttachmentIds = options.attachmentIds?.map((id) => id.trim()).filter(Boolean) ?? [];
797
+ const comments = [];
798
+ const errors = [];
799
+ for (const rawMessage of messagesRaw) {
800
+ const message = normalizeChatMessage(rawMessage);
801
+ if (!message) {
802
+ continue;
803
+ }
804
+ if (!options.includeSystemMessages && message.isSystem) {
805
+ continue;
806
+ }
807
+ if (requestedCommentIds.length > 0 && !requestedCommentIds.includes(message.id)) {
808
+ continue;
809
+ }
810
+ const attachments = resolveMessageAttachments(message, filesById);
811
+ const selectedAttachments = this.selectCommentAttachments(attachments, requestedAttachmentIds);
812
+ const downloadedFiles = [];
813
+ if (includeAttachments) {
814
+ for (const attachment of selectedAttachments) {
815
+ const chatFile = filesById.get(attachment.fileId ?? attachment.attachmentId);
816
+ if (options.imagesOnly === true && chatFile && !isImageChatFile(chatFile)) {
817
+ continue;
818
+ }
819
+ try {
820
+ downloadedFiles.push(await this.downloadChatFileAttachment(message.id, attachment, options));
821
+ }
822
+ catch (error) {
823
+ const errMessage = error instanceof Error ? error.message : String(error);
824
+ errors.push(`message ${message.id}, file ${attachment.attachmentId}: ${errMessage}`);
825
+ downloadedFiles.push({
826
+ diskFileId: attachment.fileId ?? attachment.attachmentId,
827
+ attachmentId: attachment.attachmentId,
828
+ fileId: attachment.fileId,
829
+ fileName: attachment.name,
830
+ mimeType: guessMimeType(attachment.name),
831
+ isImage: chatFile ? isImageChatFile(chatFile) : isImageFile(attachment.name),
832
+ error: errMessage,
833
+ commentId: message.id
834
+ });
835
+ }
836
+ }
837
+ }
838
+ comments.push({
839
+ id: message.id,
840
+ authorId: message.authorId !== '0' ? message.authorId : undefined,
841
+ postDate: message.date,
842
+ postMessage: message.text,
843
+ postMessageHtml: null,
844
+ attachments: selectedAttachments,
845
+ downloadedFiles
846
+ });
847
+ }
848
+ const orderDirection = Object.values(options.order ?? { POST_DATE: 'asc' })[0]?.toLowerCase();
849
+ if (orderDirection === 'desc') {
850
+ comments.reverse();
851
+ }
852
+ return { comments, errors };
853
+ }
854
+ async getTaskCommentsLegacy(taskId, options) {
855
+ const includeAttachments = options.includeAttachments !== false;
856
+ const requestedCommentIds = options.commentIds?.map((id) => id.trim()).filter(Boolean) ?? [];
857
+ const requestedAttachmentIds = options.attachmentIds?.map((id) => id.trim()).filter(Boolean) ?? [];
858
+ const rawItems = await this.fetchTaskCommentRawItems(taskId, options);
859
+ const filteredItems = requestedCommentIds.length > 0
860
+ ? rawItems.filter((item) => requestedCommentIds.includes(String(item.ID ?? item.id ?? '')))
861
+ : rawItems;
862
+ const comments = [];
863
+ const errors = [];
864
+ for (const rawItem of filteredItems) {
865
+ const comment = normalizeTaskComment(rawItem);
866
+ if (!comment.id) {
867
+ continue;
868
+ }
869
+ const attachments = extractCommentAttachments(rawItem);
870
+ const selectedAttachments = this.selectCommentAttachments(attachments, requestedAttachmentIds);
871
+ const downloadedFiles = [];
872
+ if (includeAttachments) {
873
+ for (const attachment of selectedAttachments) {
874
+ try {
875
+ if (options.imagesOnly === true && !isImageFile(attachment.name)) {
876
+ continue;
877
+ }
878
+ downloadedFiles.push(await this.downloadCommentAttachment(comment.id, attachment, options));
879
+ }
880
+ catch (error) {
881
+ const message = error instanceof Error ? error.message : String(error);
882
+ errors.push(`comment ${comment.id}, attachment ${attachment.attachmentId}: ${message}`);
883
+ downloadedFiles.push({
884
+ diskFileId: attachment.fileId
885
+ ? toWebdavFileToken(attachment.fileId)
886
+ : attachment.attachmentId,
887
+ attachmentId: attachment.attachmentId,
888
+ fileId: attachment.fileId,
889
+ fileName: attachment.name,
890
+ mimeType: guessMimeType(attachment.name),
891
+ isImage: isImageFile(attachment.name),
892
+ error: message,
893
+ commentId: comment.id
894
+ });
895
+ }
896
+ }
897
+ }
898
+ comments.push({
899
+ ...comment,
900
+ attachments: selectedAttachments,
901
+ downloadedFiles
902
+ });
903
+ }
904
+ return { comments, errors };
905
+ }
906
+ async sendTaskCommentViaChat(taskId, chat, options) {
907
+ const text = (options.text ?? '').trim();
908
+ const filePaths = options.filePaths ?? [];
909
+ const uploadedDiskFileIds = [];
910
+ for (const filePath of filePaths) {
911
+ const { fileName, base64 } = await readLocalFileAsBase64(filePath);
912
+ const uploaded = await this.uploadFileToDisk({
913
+ folderId: options.diskFolderId,
914
+ fileName,
915
+ base64Content: base64
916
+ });
917
+ uploadedDiskFileIds.push(uploaded.diskObjectId);
918
+ }
919
+ if (uploadedDiskFileIds.length > 0) {
920
+ const commitResult = await this.makeRequest('im.disk.file.commit', {
921
+ CHAT_ID: Number(chat.chatId),
922
+ FILE_ID: uploadedDiskFileIds.map((id) => Number(id)),
923
+ MESSAGE: text || undefined
924
+ });
925
+ const messageId = commitResult?.MESSAGE_ID !== undefined
926
+ ? String(commitResult.MESSAGE_ID)
927
+ : commitResult?.messageId !== undefined
928
+ ? String(commitResult.messageId)
929
+ : undefined;
930
+ return { messageId, uploadedDiskFileIds };
931
+ }
932
+ if (!text) {
933
+ throw new Error('Comment text or filePaths is required');
934
+ }
935
+ try {
936
+ await this.makeRequest('tasks.task.chat.message.send', {
937
+ fields: {
938
+ taskId: Number(taskId),
939
+ text
940
+ }
941
+ }, { useApiV3: true });
942
+ return { uploadedDiskFileIds };
943
+ }
944
+ catch (v3Error) {
945
+ console.error('tasks.task.chat.message.send failed, fallback to im.message.add:', v3Error);
946
+ }
947
+ const messageId = await this.makeRequest('im.message.add', {
948
+ DIALOG_ID: chat.dialogId,
949
+ MESSAGE: text,
950
+ SYSTEM: 'N'
951
+ });
952
+ return {
953
+ messageId: messageId !== undefined ? String(messageId) : undefined,
954
+ uploadedDiskFileIds
955
+ };
956
+ }
957
+ async sendTaskCommentLegacy(taskId, options) {
958
+ const text = (options.text ?? '').trim();
959
+ const filePaths = options.filePaths ?? [];
960
+ const uploadedDiskFileIds = [];
961
+ const forumDocs = [];
962
+ for (const filePath of filePaths) {
963
+ const { fileName, base64 } = await readLocalFileAsBase64(filePath);
964
+ const uploaded = await this.uploadFileToDisk({
965
+ folderId: options.diskFolderId,
966
+ fileName,
967
+ base64Content: base64
968
+ });
969
+ uploadedDiskFileIds.push(uploaded.diskObjectId);
970
+ forumDocs.push(toWebdavFileToken(uploaded.diskObjectId));
971
+ }
972
+ if (!text && forumDocs.length === 0) {
973
+ throw new Error('Comment text or filePaths is required');
974
+ }
975
+ const fields = {
976
+ POST_MESSAGE: text || (forumDocs.length > 0 ? ' ' : '')
977
+ };
978
+ if (options.authorId) {
979
+ fields.AUTHOR_ID = Number(options.authorId);
980
+ }
981
+ if (forumDocs.length > 0) {
982
+ fields.UF_FORUM_MESSAGE_DOC = forumDocs;
983
+ }
984
+ const commentId = await this.makeRequest('task.commentitem.add', {
985
+ TASKID: Number(taskId),
986
+ FIELDS: fields
987
+ });
988
+ return {
989
+ commentId: String(commentId),
990
+ uploadedDiskFileIds
991
+ };
992
+ }
993
+ async sendTaskComment(taskId, options) {
994
+ const preferApi = options.preferApi ?? 'auto';
995
+ const errors = [];
996
+ let chat = null;
997
+ if (preferApi !== 'legacy') {
998
+ chat = await this.resolveTaskChat(taskId);
999
+ }
1000
+ if (chat && preferApi !== 'legacy') {
1001
+ try {
1002
+ const sent = await this.sendTaskCommentViaChat(taskId, chat, options);
1003
+ return {
1004
+ success: true,
1005
+ apiMode: 'chat',
1006
+ taskId,
1007
+ chatId: chat.chatId,
1008
+ messageId: sent.messageId,
1009
+ uploadedDiskFileIds: sent.uploadedDiskFileIds,
1010
+ errors
1011
+ };
1012
+ }
1013
+ catch (error) {
1014
+ const message = error instanceof Error ? error.message : String(error);
1015
+ errors.push(`chat API: ${message}`);
1016
+ if (preferApi === 'chat') {
1017
+ return {
1018
+ success: false,
1019
+ apiMode: 'chat',
1020
+ taskId,
1021
+ chatId: chat.chatId,
1022
+ uploadedDiskFileIds: [],
1023
+ errors
1024
+ };
1025
+ }
1026
+ }
1027
+ }
1028
+ try {
1029
+ const sent = await this.sendTaskCommentLegacy(taskId, options);
1030
+ return {
1031
+ success: true,
1032
+ apiMode: 'legacy',
1033
+ taskId,
1034
+ commentId: sent.commentId,
1035
+ uploadedDiskFileIds: sent.uploadedDiskFileIds,
1036
+ errors
1037
+ };
1038
+ }
1039
+ catch (error) {
1040
+ const message = error instanceof Error ? error.message : String(error);
1041
+ errors.push(`legacy API: ${message}`);
1042
+ return {
1043
+ success: false,
1044
+ apiMode: 'legacy',
1045
+ taskId,
1046
+ chatId: chat?.chatId,
1047
+ uploadedDiskFileIds: [],
1048
+ errors
1049
+ };
1050
+ }
1051
+ }
1052
+ matchesRequestedAttachmentId(attachment, requestedId) {
1053
+ const normalized = requestedId.replace(/^n/i, '').trim();
1054
+ if (!normalized) {
1055
+ return false;
1056
+ }
1057
+ return (attachment.attachmentId === normalized ||
1058
+ attachment.fileId === normalized ||
1059
+ (requestedId.startsWith('n') && attachment.fileId === normalized));
1060
+ }
1061
+ selectCommentAttachments(attachments, requestedIds) {
1062
+ if (requestedIds.length === 0) {
1063
+ return attachments;
1064
+ }
1065
+ const selected = [];
1066
+ for (const requestedId of requestedIds) {
1067
+ const match = attachments.find((file) => this.matchesRequestedAttachmentId(file, requestedId));
1068
+ if (match && !selected.some((item) => item.attachmentId === match.attachmentId)) {
1069
+ selected.push(match);
1070
+ }
1071
+ }
1072
+ return selected;
1073
+ }
1074
+ async downloadCommentAttachment(commentId, attached, options) {
1075
+ const taskAttached = {
1076
+ attachmentId: attached.attachmentId,
1077
+ fileId: attached.fileId,
1078
+ name: attached.name,
1079
+ size: attached.size,
1080
+ downloadUrl: attached.downloadUrl,
1081
+ viewUrl: attached.viewUrl
1082
+ };
1083
+ const downloaded = await this.downloadTaskAttachedFile(taskAttached, options);
1084
+ return {
1085
+ ...downloaded,
1086
+ commentId
1087
+ };
1088
+ }
1089
+ async getTaskComments(taskId, options = {}) {
1090
+ const preferApi = options.preferApi ?? 'auto';
1091
+ const errors = [];
1092
+ let chat = null;
1093
+ if (preferApi !== 'legacy') {
1094
+ chat = await this.resolveTaskChat(taskId);
1095
+ }
1096
+ if (chat && preferApi !== 'legacy') {
1097
+ try {
1098
+ const chatResult = await this.getTaskCommentsFromChat(taskId, chat, options);
1099
+ return {
1100
+ taskId,
1101
+ apiMode: 'chat',
1102
+ chatId: chat.chatId,
1103
+ dialogId: chat.dialogId,
1104
+ comments: chatResult.comments,
1105
+ errors: [...errors, ...chatResult.errors]
1106
+ };
1107
+ }
1108
+ catch (error) {
1109
+ const message = error instanceof Error ? error.message : String(error);
1110
+ errors.push(`chat API: ${message}`);
1111
+ if (preferApi === 'chat') {
1112
+ return {
1113
+ taskId,
1114
+ apiMode: 'chat',
1115
+ chatId: chat.chatId,
1116
+ dialogId: chat.dialogId,
1117
+ comments: [],
1118
+ errors
1119
+ };
1120
+ }
1121
+ }
1122
+ }
1123
+ const legacyResult = await this.getTaskCommentsLegacy(taskId, options);
1124
+ return {
1125
+ taskId,
1126
+ apiMode: 'legacy',
1127
+ chatId: chat?.chatId,
1128
+ dialogId: chat?.dialogId,
1129
+ comments: legacyResult.comments,
1130
+ errors: [...errors, ...legacyResult.errors]
1131
+ };
1132
+ }
576
1133
  // Project Methods (Bitrix24 workgroups)
577
1134
  async createProject(project) {
578
1135
  const result = await this.makeRequest('sonet_group.create', { fields: project });
@@ -735,6 +1292,7 @@ export class Bitrix24Client {
735
1292
  tasks_access: false,
736
1293
  projects_access: false,
737
1294
  disk_access: false,
1295
+ chat_access: false,
738
1296
  error_details: []
739
1297
  };
740
1298
  try {
@@ -757,6 +1315,9 @@ export class Bitrix24Client {
757
1315
  if (scopes.includes('disk')) {
758
1316
  results.disk_access = true;
759
1317
  }
1318
+ if (scopes.includes('im')) {
1319
+ results.chat_access = true;
1320
+ }
760
1321
  try {
761
1322
  await this.getLatestTasks(1);
762
1323
  results.tasks_access = true;
@@ -782,6 +1343,25 @@ export class Bitrix24Client {
782
1343
  results.error_details.push(`disk access failed: ${error instanceof Error ? error.message : String(error)}`);
783
1344
  }
784
1345
  }
1346
+ try {
1347
+ const latestTasks = await this.getLatestTasks(1);
1348
+ const sampleTaskId = latestTasks[0]?.ID;
1349
+ if (sampleTaskId) {
1350
+ const chat = await this.resolveTaskChat(String(sampleTaskId));
1351
+ if (chat) {
1352
+ await this.makeRequest('im.dialog.messages.get', {
1353
+ DIALOG_ID: chat.dialogId,
1354
+ LIMIT: 1
1355
+ });
1356
+ results.chat_access = true;
1357
+ }
1358
+ }
1359
+ }
1360
+ catch (error) {
1361
+ if (!results.chat_access) {
1362
+ results.error_details.push(`chat access failed: ${error instanceof Error ? error.message : String(error)}`);
1363
+ }
1364
+ }
785
1365
  return results;
786
1366
  }
787
1367
  }