@ihazz/bitrix24 1.1.11 → 1.1.13

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 (46) 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 +18 -1
  7. package/dist/src/channel.d.ts.map +1 -1
  8. package/dist/src/channel.js +1274 -71
  9. package/dist/src/channel.js.map +1 -1
  10. package/dist/src/i18n.d.ts +0 -1
  11. package/dist/src/i18n.d.ts.map +1 -1
  12. package/dist/src/i18n.js +68 -79
  13. package/dist/src/i18n.js.map +1 -1
  14. package/dist/src/inbound-handler.js +85 -7
  15. package/dist/src/inbound-handler.js.map +1 -1
  16. package/dist/src/media-service.d.ts +2 -0
  17. package/dist/src/media-service.d.ts.map +1 -1
  18. package/dist/src/media-service.js +117 -14
  19. package/dist/src/media-service.js.map +1 -1
  20. package/dist/src/message-utils.d.ts.map +1 -1
  21. package/dist/src/message-utils.js +73 -3
  22. package/dist/src/message-utils.js.map +1 -1
  23. package/dist/src/runtime.d.ts +1 -0
  24. package/dist/src/runtime.d.ts.map +1 -1
  25. package/dist/src/runtime.js.map +1 -1
  26. package/dist/src/send-service.d.ts +1 -0
  27. package/dist/src/send-service.d.ts.map +1 -1
  28. package/dist/src/send-service.js +26 -3
  29. package/dist/src/send-service.js.map +1 -1
  30. package/dist/src/state-paths.d.ts +1 -0
  31. package/dist/src/state-paths.d.ts.map +1 -1
  32. package/dist/src/state-paths.js +9 -0
  33. package/dist/src/state-paths.js.map +1 -1
  34. package/dist/src/types.d.ts +92 -0
  35. package/dist/src/types.d.ts.map +1 -1
  36. package/package.json +1 -1
  37. package/src/api.ts +62 -13
  38. package/src/channel.ts +1739 -96
  39. package/src/i18n.ts +68 -81
  40. package/src/inbound-handler.ts +110 -7
  41. package/src/media-service.ts +146 -15
  42. package/src/message-utils.ts +90 -3
  43. package/src/runtime.ts +1 -0
  44. package/src/send-service.ts +40 -2
  45. package/src/state-paths.ts +11 -0
  46. package/src/types.ts +122 -0
@@ -1,14 +1,15 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { isIP } from 'node:net';
3
3
  import { createReadStream, createWriteStream } from 'node:fs';
4
- import { writeFile, mkdir, stat, unlink, rename, realpath } from 'node:fs/promises';
5
- import { join, basename, relative, sep } from 'node:path';
4
+ import { copyFile, writeFile, mkdir, stat, unlink, rename, realpath } from 'node:fs/promises';
5
+ import { isAbsolute, join, basename, relative, resolve as resolvePath, sep } from 'node:path';
6
6
  import { Readable, Transform } from 'node:stream';
7
7
  import { pipeline } from 'node:stream/promises';
8
+ import { fileURLToPath } from 'node:url';
8
9
  import { Bitrix24Api } from './api.js';
9
10
  import type { BotContext } from './api.js';
10
11
  import type { Logger } from './types.js';
11
- import { resolveManagedMediaDir } from './state-paths.js';
12
+ import { resolveManagedMediaDir, resolveTrustedWorkspaceDirs } from './state-paths.js';
12
13
  import { defaultLogger, maskUrlForLog, serializeError } from './utils.js';
13
14
 
14
15
  export interface DownloadedMedia {
@@ -22,6 +23,11 @@ export interface UploadedMedia {
22
23
  messageId?: number;
23
24
  }
24
25
 
26
+ type ResolvedUploadPath = {
27
+ path: string;
28
+ cleanup: boolean;
29
+ };
30
+
25
31
  const MIME_MAP: Record<string, string> = {
26
32
  jpg: 'image/jpeg',
27
33
  jpeg: 'image/jpeg',
@@ -66,6 +72,11 @@ function mimeFromExtension(ext: string): string {
66
72
  return MIME_MAP[ext.toLowerCase()] ?? 'application/octet-stream';
67
73
  }
68
74
 
75
+ function isPathInside(rootPath: string, filePath: string): boolean {
76
+ const rel = relative(rootPath, filePath);
77
+ return !rel.startsWith('..') && !rel.startsWith(sep);
78
+ }
79
+
69
80
  function normalizeResponseContentType(contentType: string | null): string | undefined {
70
81
  if (!contentType) {
71
82
  return undefined;
@@ -75,6 +86,23 @@ function normalizeResponseContentType(contentType: string | null): string | unde
75
86
  return normalized ? normalized : undefined;
76
87
  }
77
88
 
89
+ function normalizeLocalUploadPath(localPath: string): string | null {
90
+ const trimmed = localPath.trim();
91
+ if (!trimmed) {
92
+ return null;
93
+ }
94
+
95
+ if (trimmed.startsWith('file://')) {
96
+ try {
97
+ return fileURLToPath(trimmed);
98
+ } catch {
99
+ return null;
100
+ }
101
+ }
102
+
103
+ return trimmed;
104
+ }
105
+
78
106
  function isPrivateHost(hostname: string): boolean {
79
107
  if (hostname === 'localhost' || hostname === '::1') return true;
80
108
 
@@ -140,6 +168,7 @@ const MAX_FILE_SIZE = 100 * 1024 * 1024;
140
168
  const DOWNLOAD_TIMEOUT_MS = 30_000;
141
169
 
142
170
  const EMPTY_BUFFER = Buffer.alloc(0);
171
+ const MANAGED_FILE_NAME_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}_(.+)$/i;
143
172
 
144
173
  class MaxFileSizeExceededError extends Error {
145
174
  readonly size: number;
@@ -241,19 +270,112 @@ export class MediaService {
241
270
  return encoded;
242
271
  }
243
272
 
244
- private async resolveManagedUploadPath(localPath: string): Promise<string | null> {
245
- await this.ensureDir();
273
+ private async stageWorkspaceUploadPath(localPath: string): Promise<string> {
274
+ const { savePath, tempPath } = this.buildManagedMediaPath(basename(localPath), 'file');
246
275
 
247
276
  try {
248
- const [mediaDir, filePath] = await Promise.all([
249
- realpath(resolveManagedMediaDir()),
250
- realpath(localPath),
251
- ]);
252
- const rel = relative(mediaDir, filePath);
253
- return !rel.startsWith('..') && !rel.startsWith(sep) ? filePath : null;
254
- } catch {
277
+ const fileStat = await stat(localPath);
278
+ if (fileStat.size > MAX_FILE_SIZE) {
279
+ throw new MaxFileSizeExceededError(fileStat.size, MAX_FILE_SIZE);
280
+ }
281
+
282
+ await copyFile(localPath, tempPath);
283
+ await rename(tempPath, savePath);
284
+ return savePath;
285
+ } catch (error) {
286
+ await unlink(tempPath).catch(() => undefined);
287
+ await unlink(savePath).catch(() => undefined);
288
+ throw error;
289
+ }
290
+ }
291
+
292
+ private async resolveManagedUploadPath(localPath: string): Promise<ResolvedUploadPath | null> {
293
+ await this.ensureDir();
294
+
295
+ const normalizedInputPath = normalizeLocalUploadPath(localPath);
296
+ if (!normalizedInputPath) {
297
+ return null;
298
+ }
299
+
300
+ const resolvedMediaDir = await realpath(resolveManagedMediaDir()).catch(() => null);
301
+ if (!resolvedMediaDir) {
255
302
  return null;
256
303
  }
304
+
305
+ const configuredWorkspaceRoots = resolveTrustedWorkspaceDirs();
306
+ const resolvedWorkspaceRoots = (await Promise.all(
307
+ configuredWorkspaceRoots.map(async (workspaceRoot) => {
308
+ try {
309
+ return await realpath(workspaceRoot);
310
+ } catch {
311
+ return null;
312
+ }
313
+ }),
314
+ )).filter((workspaceRoot): workspaceRoot is string => Boolean(workspaceRoot));
315
+
316
+ const candidatePaths = new Set<string>();
317
+ candidatePaths.add(normalizedInputPath);
318
+
319
+ if (!isAbsolute(normalizedInputPath)) {
320
+ candidatePaths.add(join(resolveManagedMediaDir(), normalizedInputPath));
321
+ for (const workspaceRoot of configuredWorkspaceRoots) {
322
+ candidatePaths.add(resolvePath(workspaceRoot, normalizedInputPath));
323
+ }
324
+ }
325
+
326
+ if (isAbsolute(normalizedInputPath)) {
327
+ const looksLikeWorkspacePath = configuredWorkspaceRoots.some((workspaceRoot) =>
328
+ isPathInside(workspaceRoot, normalizedInputPath),
329
+ );
330
+
331
+ if (looksLikeWorkspacePath) {
332
+ candidatePaths.add(join(resolveManagedMediaDir(), basename(normalizedInputPath)));
333
+ }
334
+ }
335
+
336
+ for (const candidatePath of candidatePaths) {
337
+ let filePath: string;
338
+
339
+ try {
340
+ filePath = await realpath(candidatePath);
341
+ } catch {
342
+ continue;
343
+ }
344
+
345
+ if (isPathInside(resolvedMediaDir, filePath)) {
346
+ return { path: filePath, cleanup: false };
347
+ }
348
+
349
+ for (const resolvedWorkspaceRoot of resolvedWorkspaceRoots) {
350
+ if (!isPathInside(resolvedWorkspaceRoot, filePath)) {
351
+ continue;
352
+ }
353
+
354
+ return {
355
+ path: await this.stageWorkspaceUploadPath(filePath),
356
+ cleanup: true,
357
+ };
358
+ }
359
+ }
360
+
361
+ return null;
362
+ }
363
+
364
+ private resolveOutboundFileName(fileName: string, managedPath: string): string {
365
+ const requestedBaseName = basename(fileName).trim();
366
+ const managedBaseName = basename(managedPath).trim();
367
+ const managedMatch = MANAGED_FILE_NAME_RE.exec(managedBaseName);
368
+ const logicalManagedName = managedMatch?.[1]?.trim() || managedBaseName;
369
+
370
+ if (requestedBaseName && requestedBaseName !== managedBaseName) {
371
+ return requestedBaseName;
372
+ }
373
+
374
+ if (requestedBaseName === managedBaseName && managedMatch?.[1]?.trim()) {
375
+ return managedMatch[1].trim();
376
+ }
377
+
378
+ return requestedBaseName || logicalManagedName;
257
379
  }
258
380
 
259
381
  private async fetchDownloadResponse(params: {
@@ -425,6 +547,7 @@ export class MediaService {
425
547
  message?: string;
426
548
  }): Promise<UploadedMedia> {
427
549
  const { localPath, fileName, webhookUrl, bot, dialogId, message } = params;
550
+ let stagedUploadPath: string | null = null;
428
551
 
429
552
  try {
430
553
  const managedPath = await this.resolveManagedUploadPath(localPath);
@@ -435,9 +558,12 @@ export class MediaService {
435
558
  });
436
559
  return { ok: false };
437
560
  }
561
+ if (managedPath.cleanup) {
562
+ stagedUploadPath = managedPath.path;
563
+ }
438
564
 
439
565
  // Check file size before reading
440
- const fileStat = await stat(managedPath);
566
+ const fileStat = await stat(managedPath.path);
441
567
  if (fileStat.size > MAX_FILE_SIZE) {
442
568
  this.logger.warn('File too large to upload', {
443
569
  fileName,
@@ -448,10 +574,11 @@ export class MediaService {
448
574
  }
449
575
 
450
576
  // Encode incrementally to avoid holding both the raw file and base64 string in memory.
451
- const base64Content = await this.encodeFileToBase64(managedPath);
577
+ const base64Content = await this.encodeFileToBase64(managedPath.path);
578
+ const outboundFileName = this.resolveOutboundFileName(fileName, managedPath.path);
452
579
 
453
580
  const result = await this.api.uploadFile(webhookUrl, bot, dialogId, {
454
- name: basename(fileName).trim() || basename(managedPath),
581
+ name: outboundFileName,
455
582
  content: base64Content,
456
583
  message,
457
584
  });
@@ -469,6 +596,10 @@ export class MediaService {
469
596
  error: serializeError(err),
470
597
  });
471
598
  return { ok: false };
599
+ } finally {
600
+ if (stagedUploadPath) {
601
+ await unlink(stagedUploadPath).catch(() => undefined);
602
+ }
472
603
  }
473
604
  }
474
605
 
@@ -149,6 +149,7 @@ export function markdownToBbCode(md: string): string {
149
149
  text = text.replace(/~~(.+?)~~/g, '[S]$1[/S]');
150
150
 
151
151
  // 3f. HTML inline formatting tags
152
+ text = text.replace(/<br\s*\/?>/gi, '[BR]');
152
153
  text = text.replace(/<u>([\s\S]*?)<\/u>/gi, '[U]$1[/U]');
153
154
  text = text.replace(/<b>([\s\S]*?)<\/b>/gi, '[B]$1[/B]');
154
155
  text = text.replace(/<strong>([\s\S]*?)<\/strong>/gi, '[B]$1[/B]');
@@ -158,13 +159,18 @@ export function markdownToBbCode(md: string): string {
158
159
  text = text.replace(/<del>([\s\S]*?)<\/del>/gi, '[S]$1[/S]');
159
160
  text = text.replace(/<strike>([\s\S]*?)<\/strike>/gi, '[S]$1[/S]');
160
161
 
161
- // 3g. Links: [text](url) → [URL=url]text[/URL]
162
- text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '[URL=$2]$1[/URL]');
162
+ // 3g. Links: [text](url) → Bitrix BB-code links and action links
163
+ text = text.replace(/\[([^\]]+)\]\(([\s\S]*?)\)/g, (_match, label: string, rawTarget: string) => {
164
+ return convertMarkdownLink(label, rawTarget);
165
+ });
163
166
 
164
167
  // 3h. Autolink URL: <https://...> → [URL]https://...[/URL]
165
168
  text = text.replace(/<(https?:\/\/[^>]+)>/g, '[URL]$1[/URL]');
166
169
 
167
- // 3i. Autolink email: <user@example.com> → [URL]mailto:user@example.com[/URL]
170
+ // 3i. Autolink tel URI: <tel:+79991234567> → [CALL]+79991234567[/CALL]
171
+ text = text.replace(/<tel:([^>]+)>/gi, '[CALL]$1[/CALL]');
172
+
173
+ // 3j. Autolink email: <user@example.com> → [URL]mailto:user@example.com[/URL]
168
174
  text = text.replace(/<([a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,})>/g, '[URL]mailto:$1[/URL]');
169
175
 
170
176
  // ── Phase 4: Restore placeholders ─────────────────────────────────────────
@@ -645,6 +651,87 @@ function extractMarkdownTarget(rawTarget: string): string {
645
651
  return trimmed;
646
652
  }
647
653
 
654
+ function convertMarkdownLink(label: string, rawTarget: string): string {
655
+ const target = extractMarkdownTarget(rawTarget);
656
+ const normalizedTarget = target.trim();
657
+
658
+ if (!normalizedTarget) {
659
+ return label;
660
+ }
661
+
662
+ const sendMatch = normalizedTarget.match(/^send:(.+)$/i);
663
+ if (sendMatch) {
664
+ const payload = normalizeActionLinkPayload(sendMatch[1]);
665
+ return payload ? `[SEND=${payload}]${label}[/SEND]` : `[SEND]${label}[/SEND]`;
666
+ }
667
+
668
+ const putMatch = normalizedTarget.match(/^put:(.+)$/i);
669
+ if (putMatch) {
670
+ const payload = normalizeActionLinkPayload(putMatch[1]);
671
+ return payload ? `[PUT=${payload}]${label}[/PUT]` : `[PUT]${label}[/PUT]`;
672
+ }
673
+
674
+ const callMatch = normalizedTarget.match(/^(?:call|tel):(.+)$/i);
675
+ if (callMatch) {
676
+ const phone = callMatch[1].trim();
677
+ return phone ? `[CALL=${phone}]${label}[/CALL]` : `[CALL]${label}[/CALL]`;
678
+ }
679
+
680
+ const userMatch = normalizedTarget.match(/^user:(.+)$/i);
681
+ if (userMatch) {
682
+ return `[USER=${userMatch[1].trim()}]${label}[/USER]`;
683
+ }
684
+
685
+ const chatMatch = normalizedTarget.match(/^chat:(.+)$/i);
686
+ if (chatMatch) {
687
+ return `[CHAT=${chatMatch[1].trim()}]${label}[/CHAT]`;
688
+ }
689
+
690
+ const contextMatch = normalizedTarget.match(/^context:(.+)$/i);
691
+ if (contextMatch) {
692
+ return `[context=${contextMatch[1].trim()}]${label}[/context]`;
693
+ }
694
+
695
+ const diskMatch = normalizedTarget.match(/^disk:(.+)$/i);
696
+ if (diskMatch) {
697
+ return `[disk=${diskMatch[1].trim()}]`;
698
+ }
699
+
700
+ const timestampMatch = normalizedTarget.match(/^timestamp:(.+)$/i);
701
+ if (timestampMatch) {
702
+ const timestampMarkup = buildTimestampBbCode(timestampMatch[1].trim());
703
+ if (timestampMarkup) {
704
+ return timestampMarkup;
705
+ }
706
+ }
707
+
708
+ return `[URL=${normalizedTarget}]${label}[/URL]`;
709
+ }
710
+
711
+ function normalizeActionLinkPayload(value: string): string {
712
+ return value.trim().replace(/^\/\//, '/');
713
+ }
714
+
715
+ function buildTimestampBbCode(value: string): string {
716
+ if (!value) {
717
+ return '';
718
+ }
719
+
720
+ const [timestamp, rawQuery = ''] = value.split('?', 2);
721
+ const normalizedTimestamp = timestamp.trim();
722
+ if (!normalizedTimestamp) {
723
+ return '';
724
+ }
725
+
726
+ const params = new URLSearchParams(rawQuery);
727
+ const format = params.get('format')?.trim();
728
+ if (format) {
729
+ return `[timestamp=${normalizedTimestamp} format=${format}]`;
730
+ }
731
+
732
+ return `[timestamp=${normalizedTimestamp}]`;
733
+ }
734
+
648
735
  function normalizeBitrixImageUrl(rawUrl: string): string {
649
736
  const trimmed = rawUrl.trim();
650
737
  if (!trimmed) return trimmed;
package/src/runtime.ts CHANGED
@@ -2,6 +2,7 @@ interface ReplyPayload {
2
2
  text?: string;
3
3
  mediaUrl?: string;
4
4
  mediaUrls?: string[];
5
+ replyToId?: string;
5
6
  isError?: boolean;
6
7
  channelData?: Record<string, unknown>;
7
8
  }
@@ -41,6 +41,7 @@ export class SendService {
41
41
  options?: {
42
42
  keyboard?: B24Keyboard;
43
43
  convertMarkdown?: boolean;
44
+ replyToMessageId?: number;
44
45
  forwardMessages?: number[];
45
46
  system?: boolean;
46
47
  },
@@ -49,8 +50,41 @@ export class SendService {
49
50
  ? markdownToBbCode(text)
50
51
  : text;
51
52
 
52
- const chunks = splitMessage(convertedText);
53
- if (chunks.length === 0) return { ok: true };
53
+ const chunks = convertedText.length > 0 ? splitMessage(convertedText) : [];
54
+ if (chunks.length === 0) {
55
+ if (!options?.forwardMessages?.length) {
56
+ return { ok: true };
57
+ }
58
+
59
+ const msgOptions: {
60
+ forwardMessages?: number[];
61
+ replyToMessageId?: number;
62
+ system?: boolean;
63
+ } = {
64
+ forwardMessages: options.forwardMessages,
65
+ };
66
+
67
+ if (options?.replyToMessageId) {
68
+ msgOptions.replyToMessageId = options.replyToMessageId;
69
+ }
70
+ if (options?.system !== undefined) {
71
+ msgOptions.system = options.system;
72
+ }
73
+
74
+ try {
75
+ const messageId = await this.api.sendMessage(
76
+ ctx.webhookUrl,
77
+ ctx.bot,
78
+ ctx.dialogId,
79
+ null,
80
+ msgOptions,
81
+ );
82
+ return { ok: true, messageId };
83
+ } catch (error) {
84
+ this.logger.error('Failed to send forwarded message without text', { error: serializeError(error) });
85
+ throw error;
86
+ }
87
+ }
54
88
  let lastMessageId: number | undefined;
55
89
 
56
90
  for (let i = 0; i < chunks.length; i++) {
@@ -58,6 +92,7 @@ export class SendService {
58
92
  const isLast = i === chunks.length - 1;
59
93
  const msgOptions: {
60
94
  keyboard?: B24Keyboard;
95
+ replyToMessageId?: number;
61
96
  forwardMessages?: number[];
62
97
  system?: boolean;
63
98
  } = {};
@@ -65,6 +100,9 @@ export class SendService {
65
100
  if (isLast && options?.keyboard) {
66
101
  msgOptions.keyboard = options.keyboard;
67
102
  }
103
+ if (isFirst && options?.replyToMessageId) {
104
+ msgOptions.replyToMessageId = options.replyToMessageId;
105
+ }
68
106
  if (isFirst && options?.forwardMessages?.length) {
69
107
  msgOptions.forwardMessages = options.forwardMessages;
70
108
  }
@@ -23,6 +23,17 @@ export function resolveManagedMediaDir(env: NodeJS.ProcessEnv = process.env): st
23
23
  return join(resolveOpenClawStateDir(env), 'media', 'bitrix24');
24
24
  }
25
25
 
26
+ export function resolveTrustedWorkspaceDirs(env: NodeJS.ProcessEnv = process.env): string[] {
27
+ const roots = [
28
+ env.OPENCLAW_WORKSPACE_DIR?.trim(),
29
+ env.CLAWDBOT_WORKSPACE_DIR?.trim(),
30
+ join(resolveOpenClawStateDir(env), 'workspace'),
31
+ join(resolveHomeDir(env), 'workspace'),
32
+ ].filter((value): value is string => Boolean(value));
33
+
34
+ return [...new Set(roots.map((root) => resolvePath(root)))];
35
+ }
36
+
26
37
  export function resolvePollingStateDir(env: NodeJS.ProcessEnv = process.env): string {
27
38
  return join(resolveOpenClawStateDir(env), 'state', 'bitrix24');
28
39
  }
package/src/types.ts CHANGED
@@ -346,12 +346,134 @@ export interface KeyboardNewline {
346
346
  /** B24 keyboard: flat array with NEWLINE separators between rows */
347
347
  export type B24Keyboard = (KeyboardButton | KeyboardNewline)[];
348
348
 
349
+ export type B24AttachColorToken = 'primary' | 'secondary' | 'alert' | 'base';
350
+
351
+ export interface B24AttachMessageBlock {
352
+ MESSAGE: string;
353
+ }
354
+
355
+ export interface B24AttachLinkValue {
356
+ LINK: string;
357
+ NAME?: string;
358
+ DESC?: string;
359
+ HTML?: string;
360
+ PREVIEW?: string;
361
+ WIDTH?: number;
362
+ HEIGHT?: number;
363
+ USER_ID?: number;
364
+ CHAT_ID?: number;
365
+ NETWORK_ID?: string;
366
+ }
367
+
368
+ export interface B24AttachLinkBlock {
369
+ LINK: B24AttachLinkValue;
370
+ }
371
+
372
+ export interface B24AttachImageItem {
373
+ LINK: string;
374
+ NAME?: string;
375
+ PREVIEW?: string;
376
+ WIDTH?: number;
377
+ HEIGHT?: number;
378
+ }
379
+
380
+ export interface B24AttachImageBlock {
381
+ IMAGE: B24AttachImageItem | B24AttachImageItem[];
382
+ }
383
+
384
+ export interface B24AttachFileItem {
385
+ LINK: string;
386
+ NAME?: string;
387
+ SIZE?: number;
388
+ }
389
+
390
+ export interface B24AttachFileBlock {
391
+ FILE: B24AttachFileItem | B24AttachFileItem[];
392
+ }
393
+
394
+ export interface B24AttachDelimiterValue {
395
+ SIZE?: number;
396
+ COLOR?: string;
397
+ }
398
+
399
+ export interface B24AttachDelimiterBlock {
400
+ DELIMITER: B24AttachDelimiterValue;
401
+ }
402
+
403
+ export type B24AttachGridDisplay = 'BLOCK' | 'LINE' | 'ROW' | 'TABLE';
404
+
405
+ export interface B24AttachGridItem {
406
+ DISPLAY: B24AttachGridDisplay;
407
+ NAME?: string;
408
+ VALUE?: string;
409
+ WIDTH?: number;
410
+ HEIGHT?: number;
411
+ COLOR_TOKEN?: B24AttachColorToken;
412
+ COLOR?: string;
413
+ LINK?: string;
414
+ USER_ID?: number;
415
+ CHAT_ID?: number;
416
+ }
417
+
418
+ export interface B24AttachGridBlock {
419
+ GRID: B24AttachGridItem[];
420
+ }
421
+
422
+ export interface B24AttachUserValue {
423
+ NAME?: string;
424
+ AVATAR?: string;
425
+ LINK?: string;
426
+ USER_ID?: number;
427
+ NETWORK_ID?: string;
428
+ }
429
+
430
+ export interface B24AttachUserBlock {
431
+ USER: B24AttachUserValue;
432
+ }
433
+
434
+ export type B24AttachBlock =
435
+ | B24AttachMessageBlock
436
+ | B24AttachLinkBlock
437
+ | B24AttachImageBlock
438
+ | B24AttachFileBlock
439
+ | B24AttachDelimiterBlock
440
+ | B24AttachGridBlock
441
+ | B24AttachUserBlock;
442
+
443
+ export interface B24AttachEnvelope {
444
+ ID?: number;
445
+ COLOR_TOKEN?: B24AttachColorToken;
446
+ COLOR?: string;
447
+ BLOCKS: B24AttachBlock[];
448
+ }
449
+
450
+ export type B24Attach = B24AttachEnvelope | B24AttachBlock[];
451
+
349
452
  export interface SendMessageResult {
350
453
  ok: boolean;
351
454
  messageId?: number;
352
455
  error?: string;
353
456
  }
354
457
 
458
+ // ─── Message Action Discovery ────────────────────────────────────────────────
459
+
460
+ export interface ChannelMessageToolSchemaContribution {
461
+ properties: Record<string, unknown>;
462
+ visibility?: 'current-channel' | 'all-configured';
463
+ }
464
+
465
+ export interface ChannelMessageToolDiscovery {
466
+ actions: string[];
467
+ capabilities: string[];
468
+ schema: ChannelMessageToolSchemaContribution | ChannelMessageToolSchemaContribution[] | null;
469
+ }
470
+
471
+ export interface ExtractedToolSendTarget {
472
+ to: string;
473
+ accountId?: string;
474
+ threadId?: string;
475
+ }
476
+
355
477
  // ─── FETCH Mode Context ─────────────────────────────────────────────────────
356
478
 
357
479
  /** Context passed from PollingService to InboundHandler for FETCH events */