@ihazz/bitrix24 1.1.12 → 1.1.14

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.
Files changed (48) hide show
  1. package/README.md +77 -4
  2. package/dist/src/api.d.ts +10 -5
  3. package/dist/src/api.d.ts.map +1 -1
  4. package/dist/src/api.js +42 -8
  5. package/dist/src/api.js.map +1 -1
  6. package/dist/src/channel.d.ts +20 -1
  7. package/dist/src/channel.d.ts.map +1 -1
  8. package/dist/src/channel.js +2303 -81
  9. package/dist/src/channel.js.map +1 -1
  10. package/dist/src/i18n.d.ts +1 -0
  11. package/dist/src/i18n.d.ts.map +1 -1
  12. package/dist/src/i18n.js +79 -68
  13. package/dist/src/i18n.js.map +1 -1
  14. package/dist/src/inbound-handler.d.ts +10 -0
  15. package/dist/src/inbound-handler.d.ts.map +1 -1
  16. package/dist/src/inbound-handler.js +281 -16
  17. package/dist/src/inbound-handler.js.map +1 -1
  18. package/dist/src/media-service.d.ts +4 -0
  19. package/dist/src/media-service.d.ts.map +1 -1
  20. package/dist/src/media-service.js +147 -14
  21. package/dist/src/media-service.js.map +1 -1
  22. package/dist/src/message-utils.d.ts.map +1 -1
  23. package/dist/src/message-utils.js +113 -4
  24. package/dist/src/message-utils.js.map +1 -1
  25. package/dist/src/runtime.d.ts +1 -0
  26. package/dist/src/runtime.d.ts.map +1 -1
  27. package/dist/src/runtime.js.map +1 -1
  28. package/dist/src/send-service.d.ts +2 -1
  29. package/dist/src/send-service.d.ts.map +1 -1
  30. package/dist/src/send-service.js +34 -5
  31. package/dist/src/send-service.js.map +1 -1
  32. package/dist/src/state-paths.d.ts +1 -0
  33. package/dist/src/state-paths.d.ts.map +1 -1
  34. package/dist/src/state-paths.js +9 -0
  35. package/dist/src/state-paths.js.map +1 -1
  36. package/dist/src/types.d.ts +92 -0
  37. package/dist/src/types.d.ts.map +1 -1
  38. package/package.json +1 -1
  39. package/src/api.ts +62 -13
  40. package/src/channel.ts +3746 -843
  41. package/src/i18n.ts +81 -68
  42. package/src/inbound-handler.ts +357 -17
  43. package/src/media-service.ts +185 -15
  44. package/src/message-utils.ts +144 -4
  45. package/src/runtime.ts +1 -0
  46. package/src/send-service.ts +52 -4
  47. package/src/state-paths.ts +11 -0
  48. package/src/types.ts +122 -0
@@ -19,6 +19,8 @@ import type {
19
19
  import { Dedup } from './dedup.js';
20
20
  import { createVerboseLogger, defaultLogger } from './utils.js';
21
21
 
22
+ const USER_SCOPE_GROUP_MIRROR_DEBOUNCE_MS = 1200;
23
+
22
24
  /** Normalized fetch command context passed to onFetchCommand callback. */
23
25
  export interface FetchCommandContext {
24
26
  commandId: number;
@@ -77,6 +79,11 @@ export class InboundHandler {
77
79
  private config: Bitrix24AccountConfig;
78
80
  private logger: Logger;
79
81
  private verboseLog: boolean;
82
+ private knownBotConversations = new Map<string, number>();
83
+ private pendingUserScopeGroupMessages = new Map<string, {
84
+ timer: ReturnType<typeof setTimeout>;
85
+ resolve: (value: boolean) => void;
86
+ }>();
80
87
  private onMessage?: (ctx: B24MsgContext) => void | Promise<void>;
81
88
  private onJoinChat?: (ctx: FetchJoinChatContext) => void | Promise<void>;
82
89
  private onReactionChange?: (ctx: FetchReactionContext) => void | Promise<void>;
@@ -95,6 +102,112 @@ export class InboundHandler {
95
102
  this.onBotDelete = opts.onBotDelete;
96
103
  }
97
104
 
105
+ private pruneKnownBotConversations(now = Date.now()): void {
106
+ for (const [key, expiresAt] of this.knownBotConversations.entries()) {
107
+ if (!Number.isFinite(expiresAt) || expiresAt <= now) {
108
+ this.knownBotConversations.delete(key);
109
+ }
110
+ }
111
+ }
112
+
113
+ private buildConversationMirrorKeys(dialogId: string, chatId: string): string[] {
114
+ const keys: string[] = [];
115
+ const normalizedDialogId = String(dialogId ?? '').trim();
116
+ const normalizedChatId = String(chatId ?? '').trim();
117
+
118
+ if (normalizedDialogId) {
119
+ keys.push(`dialog:${normalizedDialogId}`);
120
+ }
121
+
122
+ if (normalizedChatId) {
123
+ keys.push(`chat:${normalizedChatId}`);
124
+ }
125
+
126
+ return keys;
127
+ }
128
+
129
+ private rememberBotConversation(dialogId: string, chatId: string): void {
130
+ const keys = this.buildConversationMirrorKeys(dialogId, chatId);
131
+ if (keys.length === 0) {
132
+ return;
133
+ }
134
+
135
+ this.pruneKnownBotConversations();
136
+ const expiresAt = Date.now() + (5 * 60 * 1000);
137
+ keys.forEach((key) => {
138
+ this.knownBotConversations.set(key, expiresAt);
139
+ });
140
+ }
141
+
142
+ private isKnownBotConversation(dialogId: string, chatId: string): boolean {
143
+ const keys = this.buildConversationMirrorKeys(dialogId, chatId);
144
+ if (keys.length === 0) {
145
+ return false;
146
+ }
147
+
148
+ this.pruneKnownBotConversations();
149
+ return keys.some((key) => this.knownBotConversations.has(key));
150
+ }
151
+
152
+ private shouldSkipMirroredUserEvent(params: {
153
+ dialogId: string;
154
+ chatId: string;
155
+ isP2P: boolean;
156
+ botId: string;
157
+ }): boolean {
158
+ if (!this.config.agentMode) {
159
+ return false;
160
+ }
161
+
162
+ if (params.isP2P) {
163
+ return params.dialogId === params.botId;
164
+ }
165
+
166
+ return this.isKnownBotConversation(params.dialogId, params.chatId);
167
+ }
168
+
169
+ private buildPendingUserScopeGroupMessageKey(messageId: string, chatId: string): string {
170
+ return `${chatId}:${messageId}`;
171
+ }
172
+
173
+ private consumePendingUserScopeGroupMessage(messageId: string, chatId: string): boolean {
174
+ const key = this.buildPendingUserScopeGroupMessageKey(messageId, chatId);
175
+ const pendingEntry = this.pendingUserScopeGroupMessages.get(key);
176
+ if (!pendingEntry) {
177
+ return false;
178
+ }
179
+
180
+ clearTimeout(pendingEntry.timer);
181
+ this.pendingUserScopeGroupMessages.delete(key);
182
+ pendingEntry.resolve(true);
183
+ return true;
184
+ }
185
+
186
+ private deferPotentialMirroredUserScopeGroupMessage(params: {
187
+ messageId: string;
188
+ chatId: string;
189
+ run: () => Promise<void>;
190
+ }): Promise<boolean> {
191
+ const key = this.buildPendingUserScopeGroupMessageKey(params.messageId, params.chatId);
192
+ const existingEntry = this.pendingUserScopeGroupMessages.get(key);
193
+ if (existingEntry) {
194
+ return Promise.resolve(true);
195
+ }
196
+
197
+ return new Promise((resolve) => {
198
+ const timer = setTimeout(async () => {
199
+ this.pendingUserScopeGroupMessages.delete(key);
200
+ try {
201
+ await params.run();
202
+ } finally {
203
+ resolve(true);
204
+ }
205
+ }, USER_SCOPE_GROUP_MIRROR_DEBOUNCE_MS);
206
+
207
+ this.pendingUserScopeGroupMessages.set(key, { timer, resolve });
208
+ });
209
+ }
210
+
98
211
  // ─── V2 FETCH mode event handling ───────────────────────────────────────
99
212
 
100
213
  /**
@@ -167,18 +280,49 @@ export class InboundHandler {
167
280
  }
168
281
  const messageId = String(rawMessageId);
169
282
 
170
- const dedupKey = `${eventScope}:${messageId}`;
171
- if (this.dedup.isDuplicate(dedupKey)) {
172
- this.logger.debug(`Fetch: duplicate message ${messageId} for scope ${eventScope}, skipping`);
173
- return true;
174
- }
175
-
176
283
  const dialogId = String(data.chat?.dialogId ?? data.message.chatId ?? data.user.id ?? '');
177
284
  if (!dialogId) {
178
285
  this.logger.warn('Fetch: message event has no dialogId, skipping');
179
286
  return true;
180
287
  }
288
+ const chatInternalId = String(data.message?.chatId ?? data.chat?.id ?? dialogId);
181
289
  const isP2P = !dialogId.startsWith('chat');
290
+ const normalizedBotId = String(fetchCtx.botId);
291
+ const normalizedSenderId = String(data.user?.id ?? '');
292
+ const normalizedAuthorId = String(data.message?.authorId ?? '');
293
+
294
+ if (eventScope === 'bot' || normalizedSenderId === normalizedBotId || normalizedAuthorId === normalizedBotId) {
295
+ this.rememberBotConversation(dialogId, chatInternalId);
296
+ if (eventScope === 'bot' && this.consumePendingUserScopeGroupMessage(messageId, chatInternalId)) {
297
+ this.logger.debug(`Fetch: canceled pending mirrored user-scope group message ${messageId}`);
298
+ }
299
+ }
300
+
301
+ if (isBitrixBackendServiceMessage(data.message?.text ?? '', data.message?.params)) {
302
+ this.logger.debug(`Fetch: skipping Bitrix backend service message ${messageId}`);
303
+ return true;
304
+ }
305
+
306
+ if (normalizedSenderId === normalizedBotId || normalizedAuthorId === normalizedBotId) {
307
+ this.logger.debug(`Fetch: skipping self-authored message ${messageId} for scope ${eventScope}`);
308
+ return true;
309
+ }
310
+
311
+ if (eventScope === 'user' && this.shouldSkipMirroredUserEvent({
312
+ dialogId,
313
+ chatId: chatInternalId,
314
+ isP2P,
315
+ botId: normalizedBotId,
316
+ })) {
317
+ this.logger.debug(`Fetch: skipping mirrored user-scope message ${messageId}`);
318
+ return true;
319
+ }
320
+
321
+ const dedupKey = `${eventScope}:${messageId}`;
322
+ if (this.dedup.isDuplicate(dedupKey)) {
323
+ this.logger.debug(`Fetch: duplicate message ${messageId} for scope ${eventScope}, skipping`);
324
+ return true;
325
+ }
182
326
 
183
327
  // Extract file attachments from message params
184
328
  const media = extractFilesFromParams(data.message.params);
@@ -208,7 +352,7 @@ export class InboundHandler {
208
352
  senderName: data.user?.name ?? dialogId,
209
353
  senderFirstName: data.user?.firstName,
210
354
  chatId: dialogId,
211
- chatInternalId: String(data.message?.chatId ?? dialogId),
355
+ chatInternalId,
212
356
  chatName: data.chat?.name,
213
357
  chatType: data.chat?.type,
214
358
  messageId,
@@ -231,6 +375,16 @@ export class InboundHandler {
231
375
  memberId: '',
232
376
  };
233
377
 
378
+ if (eventScope === 'user' && !isP2P && this.config.agentMode && !this.isKnownBotConversation(dialogId, chatInternalId)) {
379
+ return this.deferPotentialMirroredUserScopeGroupMessage({
380
+ messageId,
381
+ chatId: chatInternalId,
382
+ run: async () => {
383
+ await this.onMessage?.(ctx);
384
+ },
385
+ });
386
+ }
387
+
234
388
  await this.onMessage?.(ctx);
235
389
  return true;
236
390
  }
@@ -269,14 +423,44 @@ export class InboundHandler {
269
423
  this.logger.warn('Fetch: reaction event has no dialogId, skipping', { eventId: item.eventId });
270
424
  return true;
271
425
  }
426
+ const chatId = String(data.chat?.id ?? data.message.chatId ?? dialogId);
427
+ const isP2P = !dialogId.startsWith('chat');
428
+ const normalizedBotId = String(fetchCtx.botId);
429
+ const normalizedSenderId = String(data.user.id ?? '');
430
+ const normalizedMessageAuthorId = String(data.message.authorId ?? '');
431
+
432
+ if (eventScope === 'bot' || normalizedSenderId === normalizedBotId || normalizedMessageAuthorId === normalizedBotId) {
433
+ this.rememberBotConversation(dialogId, chatId);
434
+ }
435
+
436
+ if (normalizedSenderId === normalizedBotId) {
437
+ this.logger.debug(`Fetch: skipping self-authored reaction ${messageId} for scope ${eventScope}`);
438
+ return true;
439
+ }
440
+
441
+ if (eventScope === 'user' && this.shouldSkipMirroredUserEvent({
442
+ dialogId,
443
+ chatId,
444
+ isP2P,
445
+ botId: normalizedBotId,
446
+ })) {
447
+ this.logger.debug(`Fetch: skipping mirrored user-scope reaction ${messageId}`);
448
+ return true;
449
+ }
450
+
451
+ const dedupKey = `${eventScope}:reaction:${messageId}:${normalizedSenderId}:${action}:${String(data.reaction ?? '')}`;
452
+ if (this.dedup.isDuplicate(dedupKey)) {
453
+ this.logger.debug(`Fetch: duplicate reaction ${messageId} for scope ${eventScope}, skipping`);
454
+ return true;
455
+ }
272
456
 
273
457
  await this.onReactionChange?.({
274
458
  eventScope,
275
- senderId: String(data.user.id ?? ''),
459
+ senderId: normalizedSenderId,
276
460
  dialogId,
277
- chatId: String(data.chat?.id ?? data.message.chatId ?? dialogId),
461
+ chatId,
278
462
  messageId,
279
- messageAuthorId: String(data.message.authorId ?? ''),
463
+ messageAuthorId: normalizedMessageAuthorId,
280
464
  reaction: String(data.reaction ?? ''),
281
465
  action,
282
466
  language: data.language,
@@ -300,6 +484,8 @@ export class InboundHandler {
300
484
  return true;
301
485
  }
302
486
 
487
+ this.rememberBotConversation(data.dialogId, String(data.chat.id ?? data.dialogId));
488
+
303
489
  const joinCtx: FetchJoinChatContext = {
304
490
  senderId: String(data.user?.id ?? data.dialogId),
305
491
  dialogId: data.dialogId,
@@ -348,6 +534,7 @@ export class InboundHandler {
348
534
  return true;
349
535
  }
350
536
  const isP2P = !dialogId.startsWith('chat');
537
+ this.rememberBotConversation(dialogId, String(data.chat?.id ?? data.message?.chatId ?? dialogId));
351
538
 
352
539
  const cmdCtx: FetchCommandContext = {
353
540
  commandId,
@@ -448,6 +635,12 @@ export class InboundHandler {
448
635
 
449
636
  destroy(): void {
450
637
  this.dedup.destroy();
638
+ this.knownBotConversations.clear();
639
+ for (const pendingEntry of this.pendingUserScopeGroupMessages.values()) {
640
+ clearTimeout(pendingEntry.timer);
641
+ pendingEntry.resolve(true);
642
+ }
643
+ this.pendingUserScopeGroupMessages.clear();
451
644
  }
452
645
  }
453
646
 
@@ -466,6 +659,126 @@ function normalizeMessageParams(params: B24V2MessageParams | undefined): Record<
466
659
  return params;
467
660
  }
468
661
 
662
+ function extractAttachBlockMessages(params: B24V2MessageParams | undefined): string[] {
663
+ const normalizedParams = normalizeMessageParams(params);
664
+ const rawAttach = normalizedParams.ATTACH;
665
+ if (!Array.isArray(rawAttach)) {
666
+ return [];
667
+ }
668
+
669
+ return rawAttach.flatMap((attachItem) => {
670
+ if (!attachItem || typeof attachItem !== 'object') {
671
+ return [];
672
+ }
673
+
674
+ const rawBlocks = (attachItem as Record<string, unknown>).BLOCKS;
675
+ if (!Array.isArray(rawBlocks)) {
676
+ return [];
677
+ }
678
+
679
+ return rawBlocks
680
+ .map((block) => (
681
+ block && typeof block === 'object'
682
+ ? String((block as Record<string, unknown>).MESSAGE ?? '').trim().toLowerCase()
683
+ : ''
684
+ ))
685
+ .filter(Boolean);
686
+ });
687
+ }
688
+
689
+ function isBitrixBackendServiceMessage(text: string, params: B24V2MessageParams | undefined): boolean {
690
+ if (text.trim().toLowerCase() !== 'backend') {
691
+ return false;
692
+ }
693
+
694
+ const blockMessages = extractAttachBlockMessages(params);
695
+ if (blockMessages.length === 0) {
696
+ return false;
697
+ }
698
+
699
+ const hasEvent = blockMessages.some((message) => message.startsWith('event:'));
700
+ const hasTool = blockMessages.some((message) => message === 'tool: im');
701
+ const hasCategory = blockMessages.some((message) => message === 'category: chat');
702
+
703
+ return hasEvent && hasTool && hasCategory;
704
+ }
705
+
706
+ function normalizeFileRecords(rawFiles: unknown): Record<string, Record<string, unknown>> {
707
+ if (!rawFiles || typeof rawFiles !== 'object') {
708
+ return {};
709
+ }
710
+
711
+ if (Array.isArray(rawFiles)) {
712
+ return rawFiles.reduce<Record<string, Record<string, unknown>>>((acc, item) => {
713
+ if (!item || typeof item !== 'object') {
714
+ return acc;
715
+ }
716
+
717
+ const normalizedItem = item as Record<string, unknown>;
718
+ const rawId = normalizedItem.id ?? normalizedItem.ID;
719
+ if (rawId == null) {
720
+ return acc;
721
+ }
722
+
723
+ acc[String(rawId)] = normalizedItem;
724
+ return acc;
725
+ }, {});
726
+ }
727
+
728
+ return rawFiles as Record<string, Record<string, unknown>>;
729
+ }
730
+
731
+ function normalizeFileExtension(rawExtension: unknown, fileName?: string): string {
732
+ const explicitExtension = typeof rawExtension === 'string'
733
+ ? rawExtension.trim().replace(/^\.+/, '')
734
+ : '';
735
+ if (explicitExtension) {
736
+ return explicitExtension;
737
+ }
738
+
739
+ const normalizedName = typeof fileName === 'string' ? fileName.trim() : '';
740
+ const lastDotIndex = normalizedName.lastIndexOf('.');
741
+ if (lastDotIndex <= 0 || lastDotIndex === normalizedName.length - 1) {
742
+ return '';
743
+ }
744
+
745
+ return normalizedName.slice(lastDotIndex + 1).trim().replace(/^\.+/, '');
746
+ }
747
+
748
+ function normalizeFileName(params: {
749
+ fileId: string;
750
+ rawName: unknown;
751
+ rawExtension: unknown;
752
+ }): string {
753
+ const normalizedName = typeof params.rawName === 'string'
754
+ ? params.rawName.trim()
755
+ : '';
756
+ if (normalizedName) {
757
+ return normalizedName;
758
+ }
759
+
760
+ const extension = normalizeFileExtension(params.rawExtension);
761
+ return extension ? `file_${params.fileId}.${extension}` : `file_${params.fileId}`;
762
+ }
763
+
764
+ function normalizeMediaType(rawType: unknown, extension: string): 'image' | 'file' {
765
+ const normalizedType = typeof rawType === 'string'
766
+ ? rawType.trim().toLowerCase()
767
+ : '';
768
+
769
+ if (normalizedType === 'image') {
770
+ return 'image';
771
+ }
772
+
773
+ if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg', 'tiff', 'tif', 'ico'].includes(
774
+ extension.toLowerCase(),
775
+ )) {
776
+ return 'image';
777
+ }
778
+
779
+ return 'file';
780
+ }
781
+
469
782
  function extractFilesFromParams(params: B24V2MessageParams | undefined): B24MediaItem[] {
470
783
  const normalizedParams = normalizeMessageParams(params);
471
784
 
@@ -475,13 +788,40 @@ function extractFilesFromParams(params: B24V2MessageParams | undefined): B24Medi
475
788
  const fileIds = Array.isArray(rawFileIds) ? rawFileIds : [rawFileIds];
476
789
  if (!fileIds.length) return [];
477
790
 
478
- return fileIds.map((id) => ({
479
- id: String(id),
480
- name: `file_${id}`,
481
- extension: '',
482
- size: 0,
483
- type: 'file' as const,
484
- }));
791
+ const fileRecords = normalizeFileRecords(normalizedParams.FILES);
792
+
793
+ return fileIds.map((id) => {
794
+ const fileId = String(id);
795
+ const fileRecord = fileRecords[fileId] ?? {};
796
+ const viewerAttrs = fileRecord.viewerAttrs;
797
+ const viewerTitle = viewerAttrs && typeof viewerAttrs === 'object'
798
+ ? (viewerAttrs as Record<string, unknown>).title
799
+ : undefined;
800
+ const rawName = fileRecord.name ?? fileRecord.NAME ?? viewerTitle;
801
+ const name = normalizeFileName({
802
+ fileId,
803
+ rawName,
804
+ rawExtension: fileRecord.extension ?? fileRecord.EXTENSION,
805
+ });
806
+ const extension = normalizeFileExtension(
807
+ fileRecord.extension ?? fileRecord.EXTENSION,
808
+ name,
809
+ );
810
+ const rawSize = Number(fileRecord.size ?? fileRecord.SIZE ?? 0);
811
+ const size = Number.isFinite(rawSize) && rawSize > 0 ? rawSize : 0;
812
+ const urlDownload = typeof fileRecord.urlDownload === 'string'
813
+ ? fileRecord.urlDownload
814
+ : undefined;
815
+
816
+ return {
817
+ id: fileId,
818
+ name,
819
+ extension,
820
+ size,
821
+ type: normalizeMediaType(fileRecord.type ?? fileRecord.TYPE, extension),
822
+ ...(urlDownload ? { urlDownload } : {}),
823
+ };
824
+ });
485
825
  }
486
826
 
487
827
  function extractReplyToMessageId(params: B24V2MessageParams | undefined): string | undefined {