@ihazz/bitrix24 1.1.1 → 1.1.3

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 (127) hide show
  1. package/README.md +5 -0
  2. package/dist/index.d.ts +30 -0
  3. package/dist/index.d.ts.map +1 -0
  4. package/dist/index.js +55 -0
  5. package/dist/index.js.map +1 -0
  6. package/dist/src/access-control.d.ts +43 -0
  7. package/dist/src/access-control.d.ts.map +1 -0
  8. package/dist/src/access-control.js +128 -0
  9. package/dist/src/access-control.js.map +1 -0
  10. package/dist/src/api.d.ts +161 -0
  11. package/dist/src/api.d.ts.map +1 -0
  12. package/dist/src/api.js +357 -0
  13. package/dist/src/api.js.map +1 -0
  14. package/dist/src/bot-avatar.d.ts +7 -0
  15. package/dist/src/bot-avatar.d.ts.map +1 -0
  16. package/dist/src/bot-avatar.js +7 -0
  17. package/dist/src/bot-avatar.js.map +1 -0
  18. package/dist/src/channel.d.ts +216 -0
  19. package/dist/src/channel.d.ts.map +1 -0
  20. package/dist/src/channel.js +2324 -0
  21. package/dist/src/channel.js.map +1 -0
  22. package/dist/src/commands.d.ts +22 -0
  23. package/dist/src/commands.d.ts.map +1 -0
  24. package/dist/src/commands.js +160 -0
  25. package/dist/src/commands.js.map +1 -0
  26. package/dist/src/config-schema.d.ts +356 -0
  27. package/dist/src/config-schema.d.ts.map +1 -0
  28. package/dist/src/config-schema.js +43 -0
  29. package/dist/src/config-schema.js.map +1 -0
  30. package/dist/src/config.d.ts +11 -0
  31. package/dist/src/config.d.ts.map +1 -0
  32. package/dist/src/config.js +50 -0
  33. package/dist/src/config.js.map +1 -0
  34. package/dist/src/dedup.d.ts +22 -0
  35. package/dist/src/dedup.d.ts.map +1 -0
  36. package/dist/src/dedup.js +49 -0
  37. package/dist/src/dedup.js.map +1 -0
  38. package/dist/src/group-access.d.ts +52 -0
  39. package/dist/src/group-access.d.ts.map +1 -0
  40. package/dist/src/group-access.js +180 -0
  41. package/dist/src/group-access.js.map +1 -0
  42. package/dist/src/history-cache.d.ts +41 -0
  43. package/dist/src/history-cache.d.ts.map +1 -0
  44. package/dist/src/history-cache.js +82 -0
  45. package/dist/src/history-cache.js.map +1 -0
  46. package/dist/src/i18n.d.ts +22 -0
  47. package/dist/src/i18n.d.ts.map +1 -0
  48. package/dist/src/i18n.js +175 -0
  49. package/dist/src/i18n.js.map +1 -0
  50. package/dist/src/inbound-handler.d.ts +92 -0
  51. package/dist/src/inbound-handler.d.ts.map +1 -0
  52. package/dist/src/inbound-handler.js +417 -0
  53. package/dist/src/inbound-handler.js.map +1 -0
  54. package/dist/src/media-service.d.ts +52 -0
  55. package/dist/src/media-service.d.ts.map +1 -0
  56. package/dist/src/media-service.js +423 -0
  57. package/dist/src/media-service.js.map +1 -0
  58. package/dist/src/message-utils.d.ts +34 -0
  59. package/dist/src/message-utils.d.ts.map +1 -0
  60. package/dist/src/message-utils.js +392 -0
  61. package/dist/src/message-utils.js.map +1 -0
  62. package/dist/src/polling-service.d.ts +39 -0
  63. package/dist/src/polling-service.d.ts.map +1 -0
  64. package/dist/src/polling-service.js +204 -0
  65. package/dist/src/polling-service.js.map +1 -0
  66. package/dist/src/rate-limiter.d.ts +22 -0
  67. package/dist/src/rate-limiter.d.ts.map +1 -0
  68. package/dist/src/rate-limiter.js +72 -0
  69. package/dist/src/rate-limiter.js.map +1 -0
  70. package/dist/src/runtime.d.ts +106 -0
  71. package/dist/src/runtime.d.ts.map +1 -0
  72. package/dist/src/runtime.js +11 -0
  73. package/dist/src/runtime.js.map +1 -0
  74. package/dist/src/send-service.d.ts +66 -0
  75. package/dist/src/send-service.d.ts.map +1 -0
  76. package/dist/src/send-service.js +177 -0
  77. package/dist/src/send-service.js.map +1 -0
  78. package/dist/src/state-paths.d.ts +3 -0
  79. package/dist/src/state-paths.d.ts.map +1 -0
  80. package/dist/src/state-paths.js +23 -0
  81. package/dist/src/state-paths.js.map +1 -0
  82. package/dist/src/types.d.ts +381 -0
  83. package/dist/src/types.d.ts.map +1 -0
  84. package/dist/src/types.js +3 -0
  85. package/dist/src/types.js.map +1 -0
  86. package/dist/src/utils.d.ts +60 -0
  87. package/dist/src/utils.d.ts.map +1 -0
  88. package/dist/src/utils.js +131 -0
  89. package/dist/src/utils.js.map +1 -0
  90. package/index.ts +1 -1
  91. package/openclaw.plugin.json +278 -1
  92. package/package.json +19 -2
  93. package/src/api.ts +0 -3
  94. package/src/channel.ts +76 -73
  95. package/src/config-schema.ts +1 -2
  96. package/src/config.ts +6 -8
  97. package/src/group-access.ts +1 -8
  98. package/src/inbound-handler.ts +128 -15
  99. package/src/media-service.ts +229 -61
  100. package/src/polling-service.ts +2 -3
  101. package/src/send-service.ts +4 -3
  102. package/src/state-paths.ts +28 -0
  103. package/src/types.ts +1 -2
  104. package/src/utils.ts +31 -4
  105. package/tests/access-control.test.ts +0 -398
  106. package/tests/api.test.ts +0 -226
  107. package/tests/channel-flow.test.ts +0 -1692
  108. package/tests/channel.test.ts +0 -842
  109. package/tests/commands.test.ts +0 -57
  110. package/tests/config.test.ts +0 -210
  111. package/tests/dedup.test.ts +0 -50
  112. package/tests/fixtures/onimbotjoinchat.json +0 -48
  113. package/tests/fixtures/onimbotmessageadd-file.json +0 -86
  114. package/tests/fixtures/onimbotmessageadd-text.json +0 -59
  115. package/tests/fixtures/onimcommandadd.json +0 -45
  116. package/tests/group-access.test.ts +0 -340
  117. package/tests/history-cache.test.ts +0 -117
  118. package/tests/i18n.test.ts +0 -90
  119. package/tests/inbound-handler.test.ts +0 -1033
  120. package/tests/index.test.ts +0 -94
  121. package/tests/media-service.test.ts +0 -319
  122. package/tests/message-utils.test.ts +0 -184
  123. package/tests/polling-service.test.ts +0 -115
  124. package/tests/rate-limiter.test.ts +0 -52
  125. package/tests/send-service.test.ts +0 -162
  126. package/tsconfig.json +0 -22
  127. package/vitest.config.ts +0 -9
@@ -0,0 +1,2324 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { basename } from 'node:path';
3
+ import { listAccountIds, resolveAccount, getConfig } from './config.js';
4
+ import { Bitrix24Api } from './api.js';
5
+ import { SendService } from './send-service.js';
6
+ import { MediaService } from './media-service.js';
7
+ import { InboundHandler } from './inbound-handler.js';
8
+ import { PollingService } from './polling-service.js';
9
+ import { normalizeAllowEntry, normalizeAllowList, checkAccessWithPairing, getWebhookUserId, } from './access-control.js';
10
+ import { checkGroupAccessPassive, checkGroupAccessWithPairing, resolveAgentWatchRules, resolveGroupAccess, } from './group-access.js';
11
+ import { DEFAULT_AVATAR_BASE64 } from './bot-avatar.js';
12
+ import { Bitrix24ApiError, createVerboseLogger, defaultLogger, CHANNEL_PREFIX_RE } from './utils.js';
13
+ import { getBitrix24Runtime } from './runtime.js';
14
+ import { OPENCLAW_COMMANDS, buildCommandsHelpText, formatModelsCommandReply } from './commands.js';
15
+ import { accessApproved, accessDenied, groupPairingPending, mediaDownloadFailed, groupChatUnsupported, onboardingMessage, ownerAndAllowedUsersOnly, personalBotOwnerOnly, watchOwnerDmNotice, } from './i18n.js';
16
+ import { HistoryCache } from './history-cache.js';
17
+ const PHASE_STATUS_DURATION_SECONDS = 8;
18
+ const PHASE_STATUS_REFRESH_GRACE_MS = 1000;
19
+ const THINKING_STATUS_DURATION_SECONDS = 30;
20
+ const THINKING_STATUS_REFRESH_GRACE_MS = 6000;
21
+ const DIRECT_TEXT_COALESCE_DEBOUNCE_MS = 200;
22
+ const DIRECT_TEXT_COALESCE_MAX_WAIT_MS = 5000;
23
+ const ACCESS_DENIED_NOTICE_COOLDOWN_MS = 60000;
24
+ const AUTO_BOT_CODE_MAX_CANDIDATES = 100;
25
+ const MEDIA_DOWNLOAD_CONCURRENCY = 2;
26
+ const MAX_WEBHOOK_BODY_BYTES = 1024 * 1024;
27
+ const DEFAULT_HISTORY_LIMIT = 100;
28
+ const HISTORY_CACHE_MAX_KEYS = 1000;
29
+ const HISTORY_CONTEXT_MARKER = '[Chat messages since your last reply - for context]';
30
+ const CROSS_CHAT_HISTORY_LIMIT = 20;
31
+ const ACCESS_DENIED_REACTION = 'crossMark';
32
+ const BOT_MESSAGE_WATCH_REACTION = 'eyes';
33
+ const FORWARDED_CONTEXT_RANGE = 5;
34
+ const REGISTERED_COMMANDS = new Set(OPENCLAW_COMMANDS.map((command) => command.command));
35
+ // ─── Emoji → B24 reaction code mapping ──────────────────────────────────
36
+ // B24 uses named reaction codes, not Unicode emoji.
37
+ // Map common Unicode emoji to their B24 equivalents.
38
+ const EMOJI_TO_B24_REACTION = {
39
+ '👍': 'like',
40
+ '👎': 'dislike',
41
+ '😂': 'faceWithTearsOfJoy',
42
+ '❤️': 'redHeart',
43
+ '❤': 'redHeart',
44
+ '😐': 'neutralFace',
45
+ '🔥': 'fire',
46
+ '😢': 'cry',
47
+ '🙂': 'slightlySmilingFace',
48
+ '😉': 'winkingFace',
49
+ '😆': 'laugh',
50
+ '😘': 'kiss',
51
+ '😲': 'wonder',
52
+ '🙁': 'slightlyFrowningFace',
53
+ '😭': 'loudlyCryingFace',
54
+ '😛': 'faceWithStuckOutTongue',
55
+ '😜': 'faceWithStuckOutTongueAndWinkingEye',
56
+ '😎': 'smilingFaceWithSunglasses',
57
+ '😕': 'confusedFace',
58
+ '😳': 'flushedFace',
59
+ '🤔': 'thinkingFace',
60
+ '😠': 'angry',
61
+ '😈': 'smilingFaceWithHorns',
62
+ '🤒': 'faceWithThermometer',
63
+ '🤦': 'facepalm',
64
+ '💩': 'poo',
65
+ '💪': 'flexedBiceps',
66
+ '👏': 'clappingHands',
67
+ '🖐️': 'raisedHand',
68
+ '🖐': 'raisedHand',
69
+ '😍': 'smilingFaceWithHeartEyes',
70
+ '🥰': 'smilingFaceWithHearts',
71
+ '🥺': 'pleadingFace',
72
+ '😌': 'relievedFace',
73
+ '🙏': 'foldedHands',
74
+ '👌': 'okHand',
75
+ '🤘': 'signHorns',
76
+ '🤟': 'loveYouGesture',
77
+ '🤡': 'clownFace',
78
+ '🥳': 'partyingFace',
79
+ '❓': 'questionMark',
80
+ '❗': 'exclamationMark',
81
+ '💡': 'lightBulb',
82
+ '💣': 'bomb',
83
+ '💤': 'sleepingSymbol',
84
+ '❌': 'crossMark',
85
+ '✅': 'whiteHeavyCheckMark',
86
+ '👀': 'eyes',
87
+ '🤝': 'handshake',
88
+ '💯': 'hundredPoints',
89
+ };
90
+ // All valid B24 reaction codes (for pass-through when code is used directly)
91
+ const B24_REACTION_CODES = new Set(Object.values(EMOJI_TO_B24_REACTION));
92
+ /**
93
+ * Resolve an emoji or B24 reaction code to a valid B24 reaction code.
94
+ * Returns null if the emoji/code is not supported.
95
+ */
96
+ function resolveB24Reaction(emojiOrCode) {
97
+ const trimmed = emojiOrCode.trim();
98
+ if (B24_REACTION_CODES.has(trimmed))
99
+ return trimmed;
100
+ return EMOJI_TO_B24_REACTION[trimmed] ?? null;
101
+ }
102
+ function toMessageId(value) {
103
+ const parsed = Number(value);
104
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
105
+ }
106
+ function escapeBbCodeText(value) {
107
+ return value
108
+ .replace(/\[/g, '(')
109
+ .replace(/\]/g, ')');
110
+ }
111
+ function buildChatContextUrl(dialogId, messageId, chatName) {
112
+ const dialogParam = encodeURIComponent(dialogId);
113
+ const messageParam = encodeURIComponent(messageId);
114
+ const label = escapeBbCodeText(chatName);
115
+ return `[URL=/online/?IM_DIALOG=${dialogParam}&IM_MESSAGE=${messageParam}]${label}[/URL]`;
116
+ }
117
+ function buildTopicsBbCode(topics) {
118
+ if (!topics || topics.length === 0) {
119
+ return undefined;
120
+ }
121
+ return topics
122
+ .map((topic) => topic.trim())
123
+ .filter(Boolean)
124
+ .map((topic) => `[b]${escapeBbCodeText(topic)}[/b]`)
125
+ .join(', ');
126
+ }
127
+ function formatQuoteTimestamp(timestamp, language) {
128
+ const locale = (language ?? 'ru').toLowerCase().slice(0, 2);
129
+ const value = timestamp ?? Date.now();
130
+ try {
131
+ return new Intl.DateTimeFormat(locale, {
132
+ year: 'numeric',
133
+ month: '2-digit',
134
+ day: '2-digit',
135
+ hour: '2-digit',
136
+ minute: '2-digit',
137
+ }).format(new Date(value)).replace(',', '');
138
+ }
139
+ catch {
140
+ return new Intl.DateTimeFormat('ru', {
141
+ year: 'numeric',
142
+ month: '2-digit',
143
+ day: '2-digit',
144
+ hour: '2-digit',
145
+ minute: '2-digit',
146
+ }).format(new Date(value)).replace(',', '');
147
+ }
148
+ }
149
+ function buildWatchQuoteText(params) {
150
+ const separator = '------------------------------------------------------';
151
+ const senderLine = `${escapeBbCodeText(params.senderName)} [${formatQuoteTimestamp(params.timestamp, params.language)}] ${params.anchor}`;
152
+ const body = escapeBbCodeText(params.body);
153
+ return [
154
+ separator,
155
+ senderLine,
156
+ body,
157
+ separator,
158
+ ].join('\n');
159
+ }
160
+ async function notifyStatus(sendService, sendCtx, config, statusMessageCode, duration = PHASE_STATUS_DURATION_SECONDS) {
161
+ if (config.showTyping === false)
162
+ return;
163
+ await sendService.sendStatus(sendCtx, statusMessageCode, duration);
164
+ }
165
+ function createReplyStatusHeartbeat(params) {
166
+ const { sendService, sendCtx, config } = params;
167
+ let timer = null;
168
+ let stopped = false;
169
+ let inFlight = false;
170
+ let activeTick = null;
171
+ let nextHeartbeatAt = Date.now();
172
+ const scheduleNext = () => {
173
+ if (stopped || config.showTyping === false)
174
+ return;
175
+ if (timer) {
176
+ clearTimeout(timer);
177
+ timer = null;
178
+ }
179
+ const delay = Math.max(0, nextHeartbeatAt - Date.now());
180
+ timer = setTimeout(() => {
181
+ void runTick();
182
+ }, delay);
183
+ };
184
+ const holdFor = (durationSeconds, graceMs = PHASE_STATUS_REFRESH_GRACE_MS) => {
185
+ const holdMs = Math.max(0, (durationSeconds * 1000) - graceMs);
186
+ const holdUntil = Date.now() + holdMs;
187
+ if (holdUntil > nextHeartbeatAt) {
188
+ nextHeartbeatAt = holdUntil;
189
+ }
190
+ if (timer) {
191
+ scheduleNext();
192
+ }
193
+ };
194
+ const runTick = () => {
195
+ const tick = (async () => {
196
+ if (stopped || inFlight || config.showTyping === false)
197
+ return;
198
+ if (Date.now() < nextHeartbeatAt) {
199
+ scheduleNext();
200
+ return;
201
+ }
202
+ inFlight = true;
203
+ try {
204
+ await notifyStatus(sendService, sendCtx, config, 'IMBOT_AGENT_ACTION_THINKING', THINKING_STATUS_DURATION_SECONDS);
205
+ }
206
+ finally {
207
+ inFlight = false;
208
+ nextHeartbeatAt = Date.now() + ((THINKING_STATUS_DURATION_SECONDS * 1000) - THINKING_STATUS_REFRESH_GRACE_MS);
209
+ scheduleNext();
210
+ }
211
+ })();
212
+ activeTick = tick;
213
+ void tick.finally(() => {
214
+ if (activeTick === tick) {
215
+ activeTick = null;
216
+ }
217
+ });
218
+ return tick;
219
+ };
220
+ return {
221
+ start: async () => {
222
+ if (stopped || timer || config.showTyping === false)
223
+ return;
224
+ if (Date.now() >= nextHeartbeatAt) {
225
+ await runTick();
226
+ return;
227
+ }
228
+ scheduleNext();
229
+ },
230
+ stop: () => {
231
+ stopped = true;
232
+ if (!timer)
233
+ return;
234
+ clearTimeout(timer);
235
+ timer = null;
236
+ },
237
+ stopAndWait: async () => {
238
+ stopped = true;
239
+ if (timer) {
240
+ clearTimeout(timer);
241
+ timer = null;
242
+ }
243
+ if (activeTick) {
244
+ await activeTick;
245
+ }
246
+ },
247
+ holdFor,
248
+ };
249
+ }
250
+ export function canCoalesceDirectMessage(msgCtx, config) {
251
+ return msgCtx.isDm
252
+ && config.dmPolicy !== 'pairing'
253
+ && msgCtx.media.length === 0
254
+ && !msgCtx.replyToMessageId
255
+ && !msgCtx.isForwarded
256
+ && msgCtx.text.trim().length > 0
257
+ && !msgCtx.text.trim().startsWith('/');
258
+ }
259
+ export function mergeBufferedDirectMessages(messages) {
260
+ const first = messages[0];
261
+ const last = messages[messages.length - 1];
262
+ return {
263
+ ...first,
264
+ text: messages
265
+ .map((message) => message.text.trim())
266
+ .filter(Boolean)
267
+ .join('\n'),
268
+ messageId: last.messageId,
269
+ language: last.language ?? first.language,
270
+ raw: last.raw,
271
+ };
272
+ }
273
+ export function mergeForwardedMessageContext(previousMsgCtx, forwardedMsgCtx) {
274
+ const previousText = previousMsgCtx.text.trim();
275
+ const forwardedText = forwardedMsgCtx.text.trim();
276
+ const mergedText = [
277
+ previousText ? '[User question about the forwarded message]' : '',
278
+ previousText,
279
+ previousText ? '[/User question]' : '',
280
+ forwardedText,
281
+ ].filter(Boolean).join('\n\n');
282
+ return {
283
+ ...forwardedMsgCtx,
284
+ text: mergedText,
285
+ media: [...previousMsgCtx.media, ...forwardedMsgCtx.media],
286
+ language: forwardedMsgCtx.language ?? previousMsgCtx.language,
287
+ replyToMessageId: undefined,
288
+ isForwarded: false,
289
+ };
290
+ }
291
+ export function resolveDirectMessageCoalesceDelay(params) {
292
+ const debounceMs = params.debounceMs ?? DIRECT_TEXT_COALESCE_DEBOUNCE_MS;
293
+ const maxWaitMs = params.maxWaitMs ?? DIRECT_TEXT_COALESCE_MAX_WAIT_MS;
294
+ const elapsedMs = Math.max(0, params.now - params.startedAt);
295
+ const remainingMs = Math.max(0, maxWaitMs - elapsedMs);
296
+ return Math.min(debounceMs, remainingMs);
297
+ }
298
+ export function shouldSkipJoinChatWelcome(params) {
299
+ if (!params.dialogId) {
300
+ return false;
301
+ }
302
+ if (params.chatType === 'chat' || params.chatType === 'open') {
303
+ return false;
304
+ }
305
+ const policy = params.dmPolicy ?? 'webhookUser';
306
+ const webhookUserId = getWebhookUserId(params.webhookUrl);
307
+ return policy === 'webhookUser'
308
+ && Boolean(webhookUserId)
309
+ && normalizeAllowEntry(params.dialogId) !== webhookUserId;
310
+ }
311
+ async function mapWithConcurrency(items, concurrency, worker) {
312
+ if (items.length === 0) {
313
+ return [];
314
+ }
315
+ const results = new Array(items.length);
316
+ let nextIndex = 0;
317
+ const poolSize = Math.max(1, Math.min(concurrency, items.length));
318
+ const runners = Array.from({ length: poolSize }, async () => {
319
+ while (nextIndex < items.length) {
320
+ const currentIndex = nextIndex;
321
+ nextIndex += 1;
322
+ results[currentIndex] = await worker(items[currentIndex], currentIndex);
323
+ }
324
+ });
325
+ await Promise.all(runners);
326
+ return results;
327
+ }
328
+ function resolveSecurityConfig(params) {
329
+ if (params.account?.config) {
330
+ return params.account.config;
331
+ }
332
+ if (params.cfg) {
333
+ return resolveAccount(params.cfg, params.accountId).config;
334
+ }
335
+ return {};
336
+ }
337
+ function resolveHistoryLimit(config) {
338
+ return Math.max(0, config.historyLimit ?? DEFAULT_HISTORY_LIMIT);
339
+ }
340
+ export function resolveConversationRef(params) {
341
+ const dialogId = String(params.dialogId);
342
+ return {
343
+ dialogId,
344
+ address: `bitrix24:${dialogId}`,
345
+ historyKey: `${params.accountId}:${dialogId}`,
346
+ peer: {
347
+ kind: params.isDirect ? 'direct' : 'group',
348
+ id: dialogId,
349
+ },
350
+ };
351
+ }
352
+ export function buildConversationSessionKey(routeSessionKey, conversation) {
353
+ return `${routeSessionKey}:${conversation.address}`;
354
+ }
355
+ function buildHistoryBody(msgCtx) {
356
+ const text = msgCtx.text.trim();
357
+ if (text) {
358
+ return text;
359
+ }
360
+ if (msgCtx.media.length === 0) {
361
+ return '';
362
+ }
363
+ const hasImage = msgCtx.media.some((mediaItem) => mediaItem.type === 'image');
364
+ return hasImage ? '<media:image>' : '<media:document>';
365
+ }
366
+ function buildConversationMeta(msgCtx) {
367
+ return {
368
+ dialogId: msgCtx.chatId,
369
+ chatId: msgCtx.chatInternalId,
370
+ chatName: msgCtx.chatName,
371
+ chatType: msgCtx.chatType,
372
+ isGroup: msgCtx.isGroup,
373
+ lastActivityAt: msgCtx.timestamp ?? Date.now(),
374
+ };
375
+ }
376
+ function appendMessageToHistory(params) {
377
+ const historyBody = (params.body ?? buildHistoryBody(params.msgCtx)).trim();
378
+ if (!historyBody) {
379
+ return;
380
+ }
381
+ params.historyCache.append({
382
+ key: params.historyKey,
383
+ limit: params.historyLimit,
384
+ entry: {
385
+ messageId: params.msgCtx.messageId,
386
+ sender: params.msgCtx.senderName,
387
+ senderId: params.msgCtx.senderId,
388
+ body: historyBody,
389
+ timestamp: params.msgCtx.timestamp ?? Date.now(),
390
+ wasMentioned: params.msgCtx.wasMentioned,
391
+ eventScope: params.msgCtx.eventScope ?? 'bot',
392
+ },
393
+ meta: buildConversationMeta(params.msgCtx),
394
+ });
395
+ }
396
+ function formatHistoryEntry(params) {
397
+ const idSuffix = params.messageId ? ` [id:${params.messageId}]` : '';
398
+ return `[${params.sender}] ${params.body}${idSuffix}`;
399
+ }
400
+ function buildHistoryContext(params) {
401
+ if (params.entries.length === 0) {
402
+ return params.currentBody;
403
+ }
404
+ const historyText = params.entries
405
+ .map((entry) => formatHistoryEntry(entry))
406
+ .join('\n');
407
+ return [
408
+ HISTORY_CONTEXT_MARKER,
409
+ historyText,
410
+ '',
411
+ params.currentBody,
412
+ ].join('\n');
413
+ }
414
+ function formatReplyContext(params) {
415
+ if (!params.replyEntry) {
416
+ return params.body;
417
+ }
418
+ return [
419
+ params.body,
420
+ '',
421
+ `[Replying to ${params.replyEntry.sender} id:${params.replyEntry.messageId}]`,
422
+ params.replyEntry.body,
423
+ '[/Replying]',
424
+ ].filter(Boolean).join('\n');
425
+ }
426
+ function resolveFetchedUserName(usersById, authorId) {
427
+ return usersById.get(authorId)?.name?.trim() || `User ${authorId}`;
428
+ }
429
+ function resolveFetchedMessageBody(text) {
430
+ const trimmed = text.trim();
431
+ return trimmed.length > 0 ? trimmed : '<empty message>';
432
+ }
433
+ function formatFetchedForwardContext(params) {
434
+ const usersById = new Map(params.context.users.map((user) => [user.id, user]));
435
+ const lines = params.context.messages
436
+ .filter((message) => message.id !== params.currentMessageId)
437
+ .map((message) => {
438
+ const sender = resolveFetchedUserName(usersById, message.authorId);
439
+ const body = resolveFetchedMessageBody(message.text);
440
+ return `[${sender} id:${message.id}] ${body}`;
441
+ })
442
+ .filter(Boolean);
443
+ if (lines.length === 0) {
444
+ return '';
445
+ }
446
+ return [
447
+ '[Bitrix24 surrounding context around this forwarded message - not the forwarded message body]',
448
+ ...lines,
449
+ '[/Bitrix24 surrounding context]',
450
+ ].join('\n');
451
+ }
452
+ function extractMentionedChatIds(text) {
453
+ const matches = [...text.matchAll(/\[CHAT=(?:chat)?(\d+)(?:[^\]]*)\][\s\S]*?\[\/CHAT\]/gi)];
454
+ return [...new Set(matches.map((match) => match[1]).filter(Boolean))];
455
+ }
456
+ function formatReferencedGroupHistory(params) {
457
+ const chatId = params.conversation.chatId ?? params.conversation.dialogId.replace(/^chat/i, '');
458
+ const chatName = params.conversation.chatName ?? params.conversation.dialogId;
459
+ const header = `[Visible group chat history: [CHAT=${chatId}]${chatName}[/CHAT]]`;
460
+ if (params.entries.length === 0) {
461
+ return [
462
+ header,
463
+ 'No messages from this chat are currently available in RAM memory.',
464
+ ].join('\n');
465
+ }
466
+ return [
467
+ header,
468
+ ...params.entries.map((entry) => formatHistoryEntry(entry)),
469
+ ].join('\n');
470
+ }
471
+ function buildCrossChatMemoryContext(params) {
472
+ const chatMentions = extractMentionedChatIds(params.query);
473
+ if (chatMentions.length === 0) {
474
+ return undefined;
475
+ }
476
+ const visibleGroupChats = params.historyCache
477
+ .listConversations()
478
+ .filter((conversation) => conversation.isGroup)
479
+ .sort((left, right) => (right.lastActivityAt ?? 0) - (left.lastActivityAt ?? 0));
480
+ const referencedChats = visibleGroupChats.filter((conversation) => {
481
+ const chatId = conversation.chatId ?? '';
482
+ return chatMentions.includes(chatId);
483
+ });
484
+ const sections = [];
485
+ if (referencedChats.length > 0) {
486
+ sections.push(...referencedChats.map((conversation) => formatReferencedGroupHistory({
487
+ conversation,
488
+ entries: params.historyCache.get(conversation.key, CROSS_CHAT_HISTORY_LIMIT),
489
+ })));
490
+ }
491
+ else if (chatMentions.length > 0) {
492
+ sections.push('[Referenced group chats]\nThe requested group chat mention is not available in RAM memory right now.');
493
+ }
494
+ return [
495
+ '[OpenClaw cross-chat memory]',
496
+ 'The following Bitrix24 group chat memory is already available to you from RAM history.',
497
+ 'Use it as trusted context for your answer.',
498
+ 'Do not say that you only see the current chat if chats or messages are listed below.',
499
+ 'Do not call tools to list chats when this memory block already contains the answer.',
500
+ '',
501
+ '[BEGIN OPENCLAW CROSS-CHAT MEMORY]',
502
+ sections.join('\n\n'),
503
+ '[END OPENCLAW CROSS-CHAT MEMORY]',
504
+ ].join('\n');
505
+ }
506
+ function buildAccessDeniedNotice(lang, policy, params) {
507
+ const effectivePolicy = policy ?? 'webhookUser';
508
+ if (effectivePolicy === 'webhookUser') {
509
+ return personalBotOwnerOnly(lang);
510
+ }
511
+ if (params?.hasAllowList) {
512
+ return ownerAndAllowedUsersOnly(lang);
513
+ }
514
+ return accessDenied(lang);
515
+ }
516
+ function normalizeTopicText(text) {
517
+ return text.toLowerCase().replace(/\s+/g, ' ').trim();
518
+ }
519
+ function tokenizeTopicText(text) {
520
+ return normalizeTopicText(text)
521
+ .split(/[^a-zа-яё0-9]+/i)
522
+ .filter(Boolean);
523
+ }
524
+ function matchesWatchTopic(messageText, topic) {
525
+ const normalizedMessage = normalizeTopicText(messageText);
526
+ const normalizedTopic = normalizeTopicText(topic);
527
+ if (!normalizedMessage || !normalizedTopic) {
528
+ return false;
529
+ }
530
+ if (normalizedMessage.includes(normalizedTopic)) {
531
+ return true;
532
+ }
533
+ const messageTokens = tokenizeTopicText(messageText);
534
+ const topicTokens = tokenizeTopicText(topic);
535
+ return topicTokens.length > 0
536
+ && topicTokens.every((topicToken) => messageTokens.some((messageToken) => messageToken.startsWith(topicToken)));
537
+ }
538
+ function findMatchingWatchRule(msgCtx, watchRules) {
539
+ if (!Array.isArray(watchRules) || watchRules.length === 0) {
540
+ return undefined;
541
+ }
542
+ const senderId = normalizeAllowEntry(msgCtx.senderId);
543
+ return watchRules.find((rule) => {
544
+ const ruleUserId = normalizeAllowEntry(rule.userId);
545
+ if (ruleUserId !== '*' && ruleUserId !== senderId) {
546
+ return false;
547
+ }
548
+ if (!Array.isArray(rule.topics) || rule.topics.length === 0) {
549
+ return true;
550
+ }
551
+ return rule.topics.some((topic) => matchesWatchTopic(msgCtx.text, topic));
552
+ });
553
+ }
554
+ class BufferedDirectMessageCoalescer {
555
+ entries = new Map();
556
+ debounceMs;
557
+ maxWaitMs;
558
+ onFlush;
559
+ logger;
560
+ destroyed = false;
561
+ constructor(params) {
562
+ this.debounceMs = params.debounceMs;
563
+ this.maxWaitMs = params.maxWaitMs;
564
+ this.onFlush = params.onFlush;
565
+ this.logger = params.logger;
566
+ }
567
+ enqueue(accountId, msgCtx) {
568
+ const key = this.getKey(accountId, msgCtx.chatId);
569
+ const current = this.entries.get(key);
570
+ if (current) {
571
+ clearTimeout(current.timer);
572
+ current.messages.push(msgCtx);
573
+ current.timer = this.createTimer(key, current.startedAt);
574
+ this.logger.debug('Buffered direct message appended', {
575
+ chatId: msgCtx.chatId,
576
+ bufferedCount: current.messages.length,
577
+ });
578
+ return;
579
+ }
580
+ const startedAt = Date.now();
581
+ this.entries.set(key, {
582
+ messages: [msgCtx],
583
+ startedAt,
584
+ timer: this.createTimer(key, startedAt),
585
+ });
586
+ this.logger.debug('Buffered direct message started', { chatId: msgCtx.chatId });
587
+ }
588
+ async flush(accountId, dialogId) {
589
+ await this.flushKey(this.getKey(accountId, dialogId));
590
+ }
591
+ take(accountId, dialogId) {
592
+ const key = this.getKey(accountId, dialogId);
593
+ const entry = this.entries.get(key);
594
+ if (!entry)
595
+ return null;
596
+ clearTimeout(entry.timer);
597
+ this.entries.delete(key);
598
+ this.logger.debug('Taking buffered direct messages', {
599
+ chatId: dialogId,
600
+ bufferedCount: entry.messages.length,
601
+ });
602
+ return entry.messages.length === 1
603
+ ? entry.messages[0]
604
+ : mergeBufferedDirectMessages(entry.messages);
605
+ }
606
+ async flushAll() {
607
+ for (const key of [...this.entries.keys()]) {
608
+ await this.flushKey(key);
609
+ }
610
+ }
611
+ destroy() {
612
+ this.destroyed = true;
613
+ for (const entry of this.entries.values()) {
614
+ clearTimeout(entry.timer);
615
+ }
616
+ this.entries.clear();
617
+ }
618
+ getKey(accountId, dialogId) {
619
+ return `${accountId}:${dialogId}`;
620
+ }
621
+ createTimer(key, startedAt) {
622
+ const delayMs = resolveDirectMessageCoalesceDelay({
623
+ startedAt,
624
+ now: Date.now(),
625
+ debounceMs: this.debounceMs,
626
+ maxWaitMs: this.maxWaitMs,
627
+ });
628
+ return setTimeout(() => {
629
+ void this.flushKey(key);
630
+ }, delayMs);
631
+ }
632
+ async flushKey(key) {
633
+ const entry = this.entries.get(key);
634
+ if (!entry || this.destroyed)
635
+ return;
636
+ clearTimeout(entry.timer);
637
+ this.entries.delete(key);
638
+ const msgCtx = entry.messages.length === 1
639
+ ? entry.messages[0]
640
+ : mergeBufferedDirectMessages(entry.messages);
641
+ try {
642
+ this.logger.debug('Flushing buffered direct messages', {
643
+ chatId: msgCtx.chatId,
644
+ bufferedCount: entry.messages.length,
645
+ });
646
+ await this.onFlush(msgCtx);
647
+ }
648
+ catch (err) {
649
+ this.logger.error('Failed to flush buffered direct messages', err);
650
+ }
651
+ }
652
+ }
653
+ let gatewayState = null;
654
+ export function __setGatewayStateForTests(state) {
655
+ gatewayState = state;
656
+ }
657
+ // ─── Default command keyboard ────────────────────────────────────────────────
658
+ /** Default keyboard shown with command responses and welcome messages. */
659
+ export const DEFAULT_COMMAND_KEYBOARD = [
660
+ { TEXT: 'Help', COMMAND: 'help', BG_COLOR_TOKEN: 'primary', DISPLAY: 'LINE' },
661
+ { TEXT: 'Status', COMMAND: 'status', DISPLAY: 'LINE' },
662
+ { TEXT: 'Commands', COMMAND: 'commands', DISPLAY: 'LINE' },
663
+ { TYPE: 'NEWLINE' },
664
+ { TEXT: 'New session', COMMAND: 'new', DISPLAY: 'LINE' },
665
+ { TEXT: 'Models', COMMAND: 'models', DISPLAY: 'LINE' },
666
+ ];
667
+ function parseRegisteredCommandTrigger(callbackData) {
668
+ const trimmed = callbackData.trim();
669
+ const isSlashCommand = trimmed.startsWith('/');
670
+ const normalized = trimmed.replace(/^\/+/, '');
671
+ if (!normalized) {
672
+ return undefined;
673
+ }
674
+ const [commandName, ...params] = normalized.split(/\s+/);
675
+ if (!isSlashCommand && !REGISTERED_COMMANDS.has(commandName)) {
676
+ return undefined;
677
+ }
678
+ return {
679
+ command: commandName,
680
+ ...(params.length > 0 ? { commandParams: params.join(' ') } : {}),
681
+ };
682
+ }
683
+ /**
684
+ * Convert OpenClaw button rows to B24 flat KEYBOARD array.
685
+ */
686
+ export function convertButtonsToKeyboard(input) {
687
+ // Normalize: accept both [[btn, btn], [btn]] (rows) and [btn, btn] (flat)
688
+ const isNested = input.length > 0 && Array.isArray(input[0]);
689
+ const rows = isNested
690
+ ? input
691
+ : [input];
692
+ const keyboard = [];
693
+ for (let i = 0; i < rows.length; i++) {
694
+ for (const btn of rows[i]) {
695
+ const b24Btn = { TEXT: btn.text, DISPLAY: 'LINE' };
696
+ const parsedCommand = btn.callback_data
697
+ ? parseRegisteredCommandTrigger(btn.callback_data)
698
+ : undefined;
699
+ if (parsedCommand) {
700
+ b24Btn.COMMAND = parsedCommand.command;
701
+ if (parsedCommand.commandParams) {
702
+ b24Btn.COMMAND_PARAMS = parsedCommand.commandParams;
703
+ }
704
+ }
705
+ else if (btn.callback_data) {
706
+ b24Btn.ACTION = 'SEND';
707
+ // Always use button text as ACTION_VALUE so the user sees readable text in chat,
708
+ // not opaque English identifiers like "answer_piano"
709
+ b24Btn.ACTION_VALUE = btn.text;
710
+ }
711
+ if (btn.style === 'primary') {
712
+ b24Btn.BG_COLOR_TOKEN = 'primary';
713
+ }
714
+ else if (btn.style === 'attention' || btn.style === 'danger') {
715
+ b24Btn.BG_COLOR_TOKEN = 'alert';
716
+ }
717
+ keyboard.push(b24Btn);
718
+ }
719
+ if (i < rows.length - 1) {
720
+ keyboard.push({ TYPE: 'NEWLINE' });
721
+ }
722
+ }
723
+ return keyboard;
724
+ }
725
+ /**
726
+ * Extract B24 keyboard from a dispatcher payload's channelData.
727
+ */
728
+ export function extractKeyboardFromPayload(payload) {
729
+ const cd = payload.channelData;
730
+ if (!cd)
731
+ return undefined;
732
+ const b24Data = cd.bitrix24;
733
+ if (b24Data?.keyboard?.length) {
734
+ return b24Data.keyboard;
735
+ }
736
+ const tgData = cd.telegram;
737
+ if (tgData?.buttons?.length) {
738
+ return convertButtonsToKeyboard(tgData.buttons);
739
+ }
740
+ return undefined;
741
+ }
742
+ /**
743
+ * Extract inline button JSON embedded in message text by the agent.
744
+ *
745
+ * Agents (especially GPT-4o) sometimes embed button markup directly in text
746
+ * as `[[{...},{...}]]` instead of using tool call parameters.
747
+ * This function detects such patterns, extracts the keyboard, and returns
748
+ * cleaned text without the JSON fragment.
749
+ */
750
+ export function extractInlineButtonsFromText(text) {
751
+ // Match [[...]] containing JSON array of button objects
752
+ const match = text.match(/\[\[\s*(\{[\s\S]*?\}(?:\s*,\s*\{[\s\S]*?\})*)\s*\]\]/);
753
+ if (!match)
754
+ return undefined;
755
+ try {
756
+ const parsed = JSON.parse(`[${match[1]}]`);
757
+ if (!Array.isArray(parsed) || parsed.length === 0)
758
+ return undefined;
759
+ // Validate it looks like button objects (must have "text" property)
760
+ if (!parsed.every((b) => typeof b === 'object' && b !== null && 'text' in b))
761
+ return undefined;
762
+ // Wrap in rows array (single row)
763
+ const buttons = [parsed];
764
+ const keyboard = convertButtonsToKeyboard(buttons);
765
+ const cleanText = text.replace(match[0], '').trim();
766
+ return { cleanText, keyboard };
767
+ }
768
+ catch {
769
+ return undefined;
770
+ }
771
+ }
772
+ function normalizeCommandReplyPayload(params) {
773
+ const { commandName, commandParams, text, language } = params;
774
+ if (commandName === 'models' && commandParams.trim() === '') {
775
+ const formattedText = formatModelsCommandReply(text, language);
776
+ if (formattedText) {
777
+ return { text: formattedText, convertMarkdown: false };
778
+ }
779
+ }
780
+ return { text };
781
+ }
782
+ /**
783
+ * Determine effective event mode from config.
784
+ */
785
+ function resolveEventMode(config) {
786
+ return config.eventMode ?? (config.callbackUrl ? 'webhook' : 'fetch');
787
+ }
788
+ /**
789
+ * Generate a stable botToken from webhookUrl if not configured.
790
+ */
791
+ function resolveBotToken(config) {
792
+ if (config.botToken)
793
+ return config.botToken;
794
+ if (!config.webhookUrl)
795
+ return null;
796
+ // Derive a stable token from webhookUrl (md5, max 32 chars — platform limit)
797
+ return createHash('md5').update(config.webhookUrl).digest('hex');
798
+ }
799
+ export function buildBotCodeCandidates(config, maxCandidates = AUTO_BOT_CODE_MAX_CANDIDATES) {
800
+ if (config.botCode) {
801
+ return [config.botCode];
802
+ }
803
+ const webhookUserId = getWebhookUserId(config.webhookUrl);
804
+ const baseCode = webhookUserId ? `openclaw_${webhookUserId}` : 'openclaw';
805
+ const safeMaxCandidates = Math.max(1, maxCandidates);
806
+ return Array.from({ length: safeMaxCandidates }, (_value, index) => {
807
+ return index === 0 ? baseCode : `${baseCode}_${index + 1}`;
808
+ });
809
+ }
810
+ function isBotCodeAlreadyTakenError(error) {
811
+ return error instanceof Bitrix24ApiError && error.code === 'BOT_CODE_ALREADY_TAKEN';
812
+ }
813
+ function isFreshBotRegistration(bot) {
814
+ return ((bot.countMessage ?? 0) === 0 &&
815
+ (bot.countCommand ?? 0) === 0 &&
816
+ (bot.countChat ?? 0) === 0 &&
817
+ (bot.countUser ?? 0) === 0);
818
+ }
819
+ async function sendInitialWelcomeToWebhookOwner(params) {
820
+ const { config, bot, sendService, language, welcomedDialogs, logger } = params;
821
+ const ownerId = getWebhookUserId(config.webhookUrl);
822
+ if (!ownerId || !config.webhookUrl || welcomedDialogs.has(ownerId)) {
823
+ return;
824
+ }
825
+ const sendCtx = {
826
+ webhookUrl: config.webhookUrl,
827
+ bot,
828
+ dialogId: ownerId,
829
+ };
830
+ const isPairing = config.dmPolicy === 'pairing';
831
+ const text = onboardingMessage(language, config.botName ?? 'OpenClaw', config.dmPolicy);
832
+ const options = isPairing ? undefined : { keyboard: DEFAULT_COMMAND_KEYBOARD };
833
+ try {
834
+ await sendService.sendText(sendCtx, text, options);
835
+ welcomedDialogs.add(ownerId);
836
+ logger.info('Initial welcome sent to webhook owner', {
837
+ dialogId: ownerId,
838
+ language: language ?? 'en',
839
+ });
840
+ }
841
+ catch (err) {
842
+ logger.warn('Failed to send initial welcome to webhook owner', err);
843
+ }
844
+ }
845
+ /**
846
+ * Register or update the bot using imbot.v2.Bot.register / Bot.update.
847
+ */
848
+ async function ensureBotRegistered(api, config, botToken, eventMode, logger) {
849
+ const { webhookUrl, callbackUrl, botName } = config;
850
+ if (!webhookUrl)
851
+ return null;
852
+ if (eventMode === 'webhook' && !callbackUrl) {
853
+ logger.warn('callbackUrl not configured for webhook mode — skipping bot registration');
854
+ return null;
855
+ }
856
+ const name = botName ?? 'OpenClaw';
857
+ const botCodeCandidates = buildBotCodeCandidates(config);
858
+ // Check if bot already exists via imbot.v2.Bot.list
859
+ try {
860
+ const listResult = await api.listBots(webhookUrl, botToken);
861
+ const existing = botCodeCandidates
862
+ .map((candidate) => listResult.bots.find((botItem) => botItem.code === candidate))
863
+ .find(Boolean);
864
+ if (existing) {
865
+ logger.info(`Bot "${existing.code}" already registered (ID=${existing.id}), updating`);
866
+ const bot = { botId: existing.id, botToken };
867
+ const updateFields = {
868
+ properties: {
869
+ name,
870
+ workPosition: 'AI Assistant',
871
+ avatar: config.botAvatar || DEFAULT_AVATAR_BASE64,
872
+ },
873
+ eventMode,
874
+ };
875
+ if (eventMode === 'webhook' && callbackUrl) {
876
+ updateFields.webhookUrl = callbackUrl;
877
+ }
878
+ await api.updateBot(webhookUrl, bot, updateFields);
879
+ return {
880
+ botId: existing.id,
881
+ language: existing.language,
882
+ isNew: false,
883
+ };
884
+ }
885
+ }
886
+ catch (err) {
887
+ logger.warn('Failed to list existing bots, will try to register', err);
888
+ }
889
+ // Register new bot via imbot.v2.Bot.register
890
+ for (const code of botCodeCandidates) {
891
+ try {
892
+ const registerFields = {
893
+ code,
894
+ properties: {
895
+ name,
896
+ workPosition: 'AI Assistant',
897
+ avatar: config.botAvatar || DEFAULT_AVATAR_BASE64,
898
+ },
899
+ type: 'personal',
900
+ eventMode,
901
+ };
902
+ if (eventMode === 'webhook' && callbackUrl) {
903
+ registerFields.webhookUrl = callbackUrl;
904
+ }
905
+ const result = await api.registerBot(webhookUrl, botToken, registerFields);
906
+ logger.info(`Bot "${code}" registered in ${eventMode} mode (ID=${result.bot.id})`);
907
+ return {
908
+ botId: result.bot.id,
909
+ language: result.bot.language,
910
+ isNew: isFreshBotRegistration(result.bot),
911
+ };
912
+ }
913
+ catch (err) {
914
+ if (!config.botCode && isBotCodeAlreadyTakenError(err)) {
915
+ logger.warn(`Bot code "${code}" already taken, trying next candidate`);
916
+ continue;
917
+ }
918
+ logger.error('Failed to register bot', err);
919
+ return null;
920
+ }
921
+ }
922
+ logger.error('Failed to register bot: exhausted automatic bot code candidates');
923
+ return null;
924
+ }
925
+ /**
926
+ * Register OpenClaw slash commands with imbot.v2.Command.register.
927
+ * V2 Command.register is idempotent and doesn't need EVENT_COMMAND_ADD URL.
928
+ */
929
+ async function ensureCommandsRegistered(api, config, bot, logger) {
930
+ const { webhookUrl } = config;
931
+ if (!webhookUrl)
932
+ return;
933
+ let registered = 0;
934
+ let skipped = 0;
935
+ for (const cmd of OPENCLAW_COMMANDS) {
936
+ try {
937
+ await api.registerCommand(webhookUrl, bot, {
938
+ command: cmd.command,
939
+ title: { en: cmd.en, ru: cmd.ru },
940
+ ...(cmd.params ? { params: { en: cmd.params, ru: cmd.params } } : {}),
941
+ });
942
+ registered++;
943
+ }
944
+ catch (err) {
945
+ const msg = err instanceof Error ? err.message : String(err);
946
+ if (msg.includes('WRONG_REQUEST') || msg.includes('already')) {
947
+ skipped++;
948
+ }
949
+ else {
950
+ logger.warn(`Failed to register command /${cmd.command}`, err);
951
+ }
952
+ }
953
+ }
954
+ logger.info(`Commands sync: ${registered} registered, ${skipped} already existed (total ${OPENCLAW_COMMANDS.length})`);
955
+ }
956
+ /**
957
+ * Handle an incoming HTTP request on the webhook route (V2 webhook mode).
958
+ */
959
+ export async function handleWebhookRequest(req, res) {
960
+ if (req.method !== 'POST') {
961
+ res.statusCode = 405;
962
+ res.end('Method Not Allowed');
963
+ return;
964
+ }
965
+ if (!gatewayState) {
966
+ res.statusCode = 503;
967
+ res.end('Channel not started');
968
+ return;
969
+ }
970
+ if (gatewayState.eventMode === 'fetch') {
971
+ res.statusCode = 200;
972
+ res.end('FETCH mode active');
973
+ return;
974
+ }
975
+ // Read raw body
976
+ const chunks = [];
977
+ let bodySize = 0;
978
+ for await (const chunk of req) {
979
+ const buffer = typeof chunk === 'string' ? Buffer.from(chunk) : chunk;
980
+ chunks.push(buffer);
981
+ bodySize += buffer.length;
982
+ if (bodySize > MAX_WEBHOOK_BODY_BYTES) {
983
+ res.statusCode = 413;
984
+ res.end('Payload Too Large');
985
+ req.destroy();
986
+ return;
987
+ }
988
+ }
989
+ const body = Buffer.concat(chunks).toString('utf-8');
990
+ try {
991
+ const handled = await gatewayState.inboundHandler.handleWebhook(body);
992
+ if (!handled) {
993
+ res.statusCode = 400;
994
+ res.end('Invalid webhook payload');
995
+ return;
996
+ }
997
+ res.statusCode = 200;
998
+ res.setHeader('Content-Type', 'application/json');
999
+ res.end(JSON.stringify({ status: 'ok' }));
1000
+ }
1001
+ catch (err) {
1002
+ defaultLogger.error('Error handling Bitrix24 V2 webhook', err);
1003
+ res.statusCode = 500;
1004
+ res.end('Webhook processing failed');
1005
+ }
1006
+ }
1007
+ // ─── Outbound adapter helpers ────────────────────────────────────────────────
1008
+ function resolveOutboundSendCtx(params) {
1009
+ const { config } = resolveAccount(params.cfg, params.accountId);
1010
+ if (!config.webhookUrl || !gatewayState)
1011
+ return null;
1012
+ return {
1013
+ webhookUrl: config.webhookUrl,
1014
+ bot: gatewayState.bot,
1015
+ dialogId: params.to,
1016
+ };
1017
+ }
1018
+ function collectOutboundMediaUrls(input) {
1019
+ const mediaUrls = [];
1020
+ if (input.mediaUrl) {
1021
+ mediaUrls.push(input.mediaUrl);
1022
+ }
1023
+ if (input.payload?.mediaUrl) {
1024
+ mediaUrls.push(input.payload.mediaUrl);
1025
+ }
1026
+ if (Array.isArray(input.payload?.mediaUrls)) {
1027
+ mediaUrls.push(...input.payload.mediaUrls.filter((item) => typeof item === 'string'));
1028
+ }
1029
+ return [...new Set(mediaUrls)];
1030
+ }
1031
+ async function uploadOutboundMedia(params) {
1032
+ let lastMessageId = '';
1033
+ let message = params.initialMessage;
1034
+ for (const mediaUrl of params.mediaUrls) {
1035
+ const result = await params.mediaService.uploadMediaToChat({
1036
+ localPath: mediaUrl,
1037
+ fileName: basename(mediaUrl),
1038
+ webhookUrl: params.sendCtx.webhookUrl,
1039
+ bot: params.sendCtx.bot,
1040
+ dialogId: params.sendCtx.dialogId,
1041
+ message: message || undefined,
1042
+ });
1043
+ if (!result.ok) {
1044
+ throw new Error(`Failed to upload media: ${basename(mediaUrl)}`);
1045
+ }
1046
+ if (result.messageId) {
1047
+ lastMessageId = String(result.messageId);
1048
+ }
1049
+ message = undefined;
1050
+ }
1051
+ return lastMessageId;
1052
+ }
1053
+ /**
1054
+ * The Bitrix24 channel plugin object.
1055
+ */
1056
+ export const bitrix24Plugin = {
1057
+ id: 'bitrix24',
1058
+ meta: {
1059
+ id: 'bitrix24',
1060
+ label: 'Bitrix24',
1061
+ selectionLabel: 'Bitrix24 (Messenger)',
1062
+ docsPath: '/channels/bitrix24',
1063
+ docsLabel: 'bitrix24',
1064
+ blurb: 'Connect to Bitrix24 Messenger via chat bot REST API (V2).',
1065
+ aliases: ['b24', 'bx24'],
1066
+ },
1067
+ capabilities: {
1068
+ chatTypes: ['direct', 'group'],
1069
+ media: true,
1070
+ reactions: true,
1071
+ threads: false,
1072
+ nativeCommands: true,
1073
+ inlineButtons: 'all',
1074
+ },
1075
+ messaging: {
1076
+ normalizeTarget: (raw) => raw.trim().replace(CHANNEL_PREFIX_RE, ''),
1077
+ targetResolver: {
1078
+ hint: 'Use a numeric chat/dialog ID, e.g. "1" or "chat42".',
1079
+ looksLikeId: (raw, _normalized) => {
1080
+ const stripped = raw.trim().replace(CHANNEL_PREFIX_RE, '');
1081
+ return /^\d+$/.test(stripped);
1082
+ },
1083
+ },
1084
+ },
1085
+ config: {
1086
+ listAccountIds: (cfg) => listAccountIds(cfg),
1087
+ resolveAccount: (cfg, accountId) => resolveAccount(cfg, accountId),
1088
+ },
1089
+ security: {
1090
+ resolveDmPolicy: (params) => {
1091
+ const securityConfig = resolveSecurityConfig(params);
1092
+ const policy = securityConfig.dmPolicy ?? 'webhookUser';
1093
+ return {
1094
+ policy,
1095
+ allowFrom: normalizeAllowList(securityConfig.allowFrom),
1096
+ policyPath: 'channels.bitrix24.dmPolicy',
1097
+ allowFromPath: 'channels.bitrix24.allowFrom',
1098
+ approveHint: 'openclaw pairing approve bitrix24 <CODE>',
1099
+ normalizeEntry: (raw) => raw.replace(CHANNEL_PREFIX_RE, ''),
1100
+ };
1101
+ },
1102
+ normalizeAllowFrom: (entry) => entry.replace(CHANNEL_PREFIX_RE, ''),
1103
+ },
1104
+ pairing: {
1105
+ idLabel: 'bitrix24UserId',
1106
+ normalizeAllowEntry: (entry) => normalizeAllowEntry(entry),
1107
+ notifyApproval: async (params) => {
1108
+ const { config: acctCfg } = resolveAccount(params.cfg);
1109
+ if (!acctCfg.webhookUrl || !gatewayState)
1110
+ return;
1111
+ const sendCtx = {
1112
+ webhookUrl: acctCfg.webhookUrl,
1113
+ bot: gatewayState.bot,
1114
+ dialogId: params.id,
1115
+ };
1116
+ try {
1117
+ await gatewayState.sendService.sendText(sendCtx, `\u2705 ${accessApproved(undefined)}`);
1118
+ }
1119
+ catch (err) {
1120
+ defaultLogger.warn('Failed to notify approved Bitrix24 user', err);
1121
+ }
1122
+ },
1123
+ },
1124
+ outbound: {
1125
+ deliveryMode: 'direct',
1126
+ textChunkLimit: 4000,
1127
+ sendText: async (ctx) => {
1128
+ const sendCtx = resolveOutboundSendCtx(ctx);
1129
+ if (!sendCtx || !gatewayState)
1130
+ throw new Error('Bitrix24 gateway not started');
1131
+ // Agent may embed button JSON in text as [[{...}]]
1132
+ let text = ctx.text;
1133
+ let keyboard;
1134
+ const extracted = extractInlineButtonsFromText(text);
1135
+ if (extracted) {
1136
+ text = extracted.cleanText;
1137
+ keyboard = extracted.keyboard;
1138
+ }
1139
+ const result = await gatewayState.sendService.sendText(sendCtx, text, keyboard ? { keyboard } : undefined);
1140
+ return { messageId: String(result.messageId ?? '') };
1141
+ },
1142
+ sendMedia: async (ctx) => {
1143
+ const sendCtx = resolveOutboundSendCtx(ctx);
1144
+ if (!sendCtx || !gatewayState)
1145
+ throw new Error('Bitrix24 gateway not started');
1146
+ const mediaUrls = collectOutboundMediaUrls({ mediaUrl: ctx.mediaUrl });
1147
+ if (mediaUrls.length > 0) {
1148
+ const messageId = await uploadOutboundMedia({
1149
+ mediaService: gatewayState.mediaService,
1150
+ sendCtx,
1151
+ mediaUrls,
1152
+ initialMessage: ctx.text,
1153
+ });
1154
+ return { messageId };
1155
+ }
1156
+ if (ctx.text) {
1157
+ const result = await gatewayState.sendService.sendText(sendCtx, ctx.text);
1158
+ return { messageId: String(result.messageId ?? '') };
1159
+ }
1160
+ return { messageId: '' };
1161
+ },
1162
+ sendPayload: async (ctx) => {
1163
+ const sendCtx = resolveOutboundSendCtx(ctx);
1164
+ if (!sendCtx || !gatewayState)
1165
+ throw new Error('Bitrix24 gateway not started');
1166
+ const keyboard = ctx.payload?.channelData
1167
+ ? extractKeyboardFromPayload({ channelData: ctx.payload.channelData })
1168
+ : undefined;
1169
+ const mediaUrls = collectOutboundMediaUrls({
1170
+ mediaUrl: ctx.mediaUrl,
1171
+ payload: ctx.payload,
1172
+ });
1173
+ if (mediaUrls.length > 0) {
1174
+ const uploadedMessageId = await uploadOutboundMedia({
1175
+ mediaService: gatewayState.mediaService,
1176
+ sendCtx,
1177
+ mediaUrls,
1178
+ });
1179
+ if (ctx.text) {
1180
+ const result = await gatewayState.sendService.sendText(sendCtx, ctx.text, keyboard ? { keyboard } : undefined);
1181
+ return { messageId: String(result.messageId ?? uploadedMessageId) };
1182
+ }
1183
+ return { messageId: uploadedMessageId };
1184
+ }
1185
+ if (ctx.text) {
1186
+ const result = await gatewayState.sendService.sendText(sendCtx, ctx.text, keyboard ? { keyboard } : undefined);
1187
+ return { messageId: String(result.messageId ?? '') };
1188
+ }
1189
+ return { messageId: '' };
1190
+ },
1191
+ },
1192
+ // ─── Actions (agent-driven: reactions, etc.) ────────────────────────────
1193
+ actions: {
1194
+ listActions: (_params) => {
1195
+ return ['react', 'send'];
1196
+ },
1197
+ supportsAction: (params) => {
1198
+ return params.action === 'react' || params.action === 'send';
1199
+ },
1200
+ handleAction: async (ctx) => {
1201
+ // Helper: wrap payload as gateway-compatible tool result
1202
+ const toolResult = (payload) => ({
1203
+ content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }],
1204
+ details: payload,
1205
+ });
1206
+ // ─── Send with buttons ──────────────────────────────────────────────
1207
+ if (ctx.action === 'send') {
1208
+ // Only intercept send when buttons are present; otherwise let gateway handle normally
1209
+ const rawButtons = ctx.params.buttons;
1210
+ if (!rawButtons)
1211
+ return null;
1212
+ const { config } = resolveAccount(ctx.cfg, ctx.accountId);
1213
+ if (!config.webhookUrl || !gatewayState)
1214
+ return null;
1215
+ const bot = gatewayState.bot;
1216
+ const to = String(ctx.params.to ?? ctx.to ?? '').trim();
1217
+ if (!to) {
1218
+ defaultLogger.warn('handleAction send: no "to" in params or ctx, falling back to gateway');
1219
+ return null;
1220
+ }
1221
+ const sendCtx = { webhookUrl: config.webhookUrl, bot, dialogId: to };
1222
+ const message = String(ctx.params.message ?? '').trim();
1223
+ // Parse buttons: may be array or JSON string
1224
+ let buttons;
1225
+ try {
1226
+ const parsed = typeof rawButtons === 'string' ? JSON.parse(rawButtons) : rawButtons;
1227
+ if (Array.isArray(parsed))
1228
+ buttons = parsed;
1229
+ }
1230
+ catch {
1231
+ // invalid buttons JSON — send without keyboard
1232
+ }
1233
+ const keyboard = buttons?.length ? convertButtonsToKeyboard(buttons) : undefined;
1234
+ try {
1235
+ const result = await gatewayState.sendService.sendText(sendCtx, message || ' ', keyboard ? { keyboard } : undefined);
1236
+ return toolResult({
1237
+ channel: 'bitrix24',
1238
+ to,
1239
+ via: 'direct',
1240
+ mediaUrl: null,
1241
+ result: { messageId: String(result.messageId ?? '') },
1242
+ });
1243
+ }
1244
+ catch (err) {
1245
+ const errMsg = err instanceof Error ? err.message : String(err);
1246
+ return toolResult({ ok: false, error: errMsg });
1247
+ }
1248
+ }
1249
+ // ─── React ────────────────────────────────────────────────────────────
1250
+ if (ctx.action !== 'react')
1251
+ return null;
1252
+ const { config } = resolveAccount(ctx.cfg, ctx.accountId);
1253
+ if (!config.webhookUrl || !gatewayState) {
1254
+ return toolResult({ ok: false, reason: 'not_started', hint: 'Bitrix24 gateway not started. Do not retry.' });
1255
+ }
1256
+ const bot = gatewayState.bot;
1257
+ const api = gatewayState.api;
1258
+ const params = ctx.params;
1259
+ // Resolve messageId: explicit param → toolContext.currentMessageId fallback
1260
+ const toolContext = ctx.toolContext;
1261
+ const rawMessageId = params.messageId ?? params.message_id ?? toolContext?.currentMessageId;
1262
+ const messageId = Number(rawMessageId);
1263
+ if (!Number.isFinite(messageId) || messageId <= 0) {
1264
+ return toolResult({ ok: false, reason: 'missing_message_id', hint: 'Valid messageId is required for Bitrix24 reactions. Do not retry.' });
1265
+ }
1266
+ const emoji = String(params.emoji ?? '').trim();
1267
+ const remove = params.remove === true || params.remove === 'true';
1268
+ if (remove) {
1269
+ // Remove reaction — need to know which one
1270
+ const reactionCode = emoji ? resolveB24Reaction(emoji) : null;
1271
+ if (!reactionCode) {
1272
+ return toolResult({ ok: false, reason: 'missing_emoji', hint: 'Emoji is required to remove a Bitrix24 reaction.' });
1273
+ }
1274
+ try {
1275
+ await api.deleteReaction(config.webhookUrl, bot, messageId, reactionCode);
1276
+ return toolResult({ ok: true, removed: true });
1277
+ }
1278
+ catch (err) {
1279
+ const errMsg = err instanceof Error ? err.message : String(err);
1280
+ return toolResult({ ok: false, reason: 'error', hint: `Failed to remove reaction: ${errMsg}. Do not retry.` });
1281
+ }
1282
+ }
1283
+ // Add reaction
1284
+ if (!emoji) {
1285
+ return toolResult({ ok: false, reason: 'missing_emoji', hint: 'Emoji is required for Bitrix24 reactions.' });
1286
+ }
1287
+ const reactionCode = resolveB24Reaction(emoji);
1288
+ if (!reactionCode) {
1289
+ return toolResult({
1290
+ ok: false,
1291
+ reason: 'REACTION_NOT_FOUND',
1292
+ emoji,
1293
+ hint: `Emoji "${emoji}" is not supported for Bitrix24 reactions. Add it to your reaction disallow list so you do not try it again.`,
1294
+ });
1295
+ }
1296
+ try {
1297
+ await api.addReaction(config.webhookUrl, bot, messageId, reactionCode);
1298
+ return toolResult({ ok: true, added: emoji });
1299
+ }
1300
+ catch (err) {
1301
+ const errMsg = err instanceof Error ? err.message : String(err);
1302
+ const isAlreadySet = errMsg.includes('REACTION_ALREADY_SET');
1303
+ if (isAlreadySet) {
1304
+ return toolResult({ ok: true, added: emoji, warning: 'Reaction already set.' });
1305
+ }
1306
+ return toolResult({ ok: false, reason: 'error', emoji, hint: `Reaction failed: ${errMsg}. Do not retry.` });
1307
+ }
1308
+ },
1309
+ },
1310
+ gateway: {
1311
+ startAccount: async (ctx) => {
1312
+ // Guard: only one account can run at a time (singleton gateway)
1313
+ if (gatewayState !== null) {
1314
+ throw new Error(`Bitrix24 channel already started for account "${gatewayState.accountId}". ` +
1315
+ `Cannot start account "${ctx.accountId}" concurrently.`);
1316
+ }
1317
+ const config = getConfig(ctx.cfg, ctx.accountId);
1318
+ const logger = createVerboseLogger(ctx.log ?? defaultLogger, Boolean(config.verboseLog));
1319
+ if (!config.webhookUrl) {
1320
+ logger.warn(`[${ctx.accountId}] no webhookUrl configured, skipping`);
1321
+ return;
1322
+ }
1323
+ // Safe to use without ! after this point
1324
+ const webhookUrl = config.webhookUrl;
1325
+ logger.info(`[${ctx.accountId}] starting Bitrix24 channel`);
1326
+ const api = new Bitrix24Api({ logger });
1327
+ const botToken = resolveBotToken(config);
1328
+ if (!botToken) {
1329
+ logger.error(`[${ctx.accountId}] cannot derive botToken — webhookUrl is missing`);
1330
+ api.destroy();
1331
+ return;
1332
+ }
1333
+ const welcomedDialogs = new Set();
1334
+ const dialogNoticeTimestamps = new Map();
1335
+ const historyCache = new HistoryCache({ maxKeys: HISTORY_CACHE_MAX_KEYS });
1336
+ // Cleanup stale denied dialog entries once per day
1337
+ const DENIED_CLEANUP_INTERVAL_MS = 24 * 60 * 60 * 1000;
1338
+ const deniedCleanupTimer = setInterval(() => {
1339
+ dialogNoticeTimestamps.clear();
1340
+ }, DENIED_CLEANUP_INTERVAL_MS);
1341
+ if (deniedCleanupTimer && typeof deniedCleanupTimer === 'object' && 'unref' in deniedCleanupTimer) {
1342
+ deniedCleanupTimer.unref();
1343
+ }
1344
+ // Determine event mode
1345
+ const eventMode = resolveEventMode(config);
1346
+ logger.info(`[${ctx.accountId}] event mode: ${eventMode}`);
1347
+ // Register or update bot on the B24 portal (V2 API)
1348
+ const botRegistration = await ensureBotRegistered(api, config, botToken, eventMode, logger);
1349
+ if (!botRegistration) {
1350
+ logger.error(`[${ctx.accountId}] bot registration failed, cannot start`);
1351
+ clearInterval(deniedCleanupTimer);
1352
+ api.destroy();
1353
+ return;
1354
+ }
1355
+ const bot = { botId: botRegistration.botId, botToken };
1356
+ const webhookOwnerId = getWebhookUserId(config.webhookUrl);
1357
+ // Sync user event subscription with agent mode setting
1358
+ if (eventMode === 'fetch') {
1359
+ try {
1360
+ if (config.agentMode) {
1361
+ await api.subscribeUserEvents(webhookUrl);
1362
+ logger.info('User events subscription active (agent mode)');
1363
+ }
1364
+ else {
1365
+ await api.unsubscribeUserEvents(webhookUrl);
1366
+ logger.debug('User events unsubscribed (agent mode off)');
1367
+ }
1368
+ }
1369
+ catch (err) {
1370
+ logger.warn('Failed to sync user events subscription', err);
1371
+ }
1372
+ }
1373
+ const sendService = new SendService(api, logger);
1374
+ const mediaService = new MediaService(api, logger);
1375
+ const hydrateReplyEntry = async (replyToMessageId) => {
1376
+ const bitrixMessageId = toMessageId(replyToMessageId);
1377
+ if (!bitrixMessageId) {
1378
+ return undefined;
1379
+ }
1380
+ try {
1381
+ const result = await api.getMessage(webhookUrl, bot, bitrixMessageId);
1382
+ return {
1383
+ sender: result.user?.name?.trim() || `User ${result.message.authorId}`,
1384
+ body: resolveFetchedMessageBody(result.message.text),
1385
+ messageId: String(result.message.id),
1386
+ };
1387
+ }
1388
+ catch (err) {
1389
+ logger.debug('Failed to hydrate reply context from Bitrix24 API', {
1390
+ replyToMessageId,
1391
+ error: err,
1392
+ });
1393
+ return undefined;
1394
+ }
1395
+ };
1396
+ const hydrateForwardedMessageContext = async (msgCtx) => {
1397
+ const currentMessageId = toMessageId(msgCtx.messageId);
1398
+ if (!currentMessageId) {
1399
+ return {
1400
+ ...msgCtx,
1401
+ isForwarded: false,
1402
+ };
1403
+ }
1404
+ try {
1405
+ const result = await api.getMessageContext(webhookUrl, bot, currentMessageId, FORWARDED_CONTEXT_RANGE);
1406
+ const currentMessage = result.messages.find((message) => message.id === currentMessageId);
1407
+ const currentBody = resolveFetchedMessageBody(currentMessage?.text ?? msgCtx.text);
1408
+ const fetchedContext = formatFetchedForwardContext({
1409
+ context: result,
1410
+ currentMessageId,
1411
+ });
1412
+ if (!currentBody && !fetchedContext) {
1413
+ return {
1414
+ ...msgCtx,
1415
+ isForwarded: false,
1416
+ };
1417
+ }
1418
+ return {
1419
+ ...msgCtx,
1420
+ text: [
1421
+ currentBody ? '[Forwarded message body]' : '',
1422
+ currentBody,
1423
+ currentBody ? '[/Forwarded message body]' : '',
1424
+ fetchedContext,
1425
+ ].filter(Boolean).join('\n\n'),
1426
+ isForwarded: false,
1427
+ };
1428
+ }
1429
+ catch (err) {
1430
+ logger.debug('Failed to hydrate forwarded message context from Bitrix24 API', {
1431
+ messageId: msgCtx.messageId,
1432
+ chatId: msgCtx.chatId,
1433
+ error: err,
1434
+ });
1435
+ return {
1436
+ ...msgCtx,
1437
+ isForwarded: false,
1438
+ };
1439
+ }
1440
+ };
1441
+ const processAllowedMessage = async (msgCtx) => {
1442
+ const runtime = getBitrix24Runtime();
1443
+ const cfg = runtime.config.loadConfig();
1444
+ const conversation = resolveConversationRef({
1445
+ accountId: ctx.accountId,
1446
+ dialogId: msgCtx.chatId,
1447
+ isDirect: msgCtx.isDm,
1448
+ });
1449
+ const sendCtx = {
1450
+ webhookUrl,
1451
+ bot,
1452
+ dialogId: conversation.dialogId,
1453
+ };
1454
+ const historyKey = conversation.historyKey;
1455
+ const historyLimit = resolveHistoryLimit(config);
1456
+ const fallbackHistoryBody = buildHistoryBody(msgCtx);
1457
+ let downloadedMedia = [];
1458
+ let historyRecorded = false;
1459
+ const recordHistory = (bodyOverride) => {
1460
+ if (historyRecorded) {
1461
+ return;
1462
+ }
1463
+ const historyBody = (bodyOverride ?? fallbackHistoryBody).trim();
1464
+ if (!historyBody) {
1465
+ return;
1466
+ }
1467
+ appendMessageToHistory({
1468
+ historyCache,
1469
+ historyKey,
1470
+ historyLimit,
1471
+ msgCtx,
1472
+ body: historyBody,
1473
+ });
1474
+ historyRecorded = true;
1475
+ };
1476
+ try {
1477
+ const replyStatusHeartbeat = createReplyStatusHeartbeat({
1478
+ sendService,
1479
+ sendCtx,
1480
+ config,
1481
+ });
1482
+ // Download media files if present
1483
+ let mediaFields = {};
1484
+ if (msgCtx.media.length > 0) {
1485
+ await notifyStatus(sendService, sendCtx, config, 'IMBOT_AGENT_ACTION_PROCESSING');
1486
+ replyStatusHeartbeat.holdFor(PHASE_STATUS_DURATION_SECONDS);
1487
+ downloadedMedia = (await mapWithConcurrency(msgCtx.media, MEDIA_DOWNLOAD_CONCURRENCY, (mediaItem) => mediaService.downloadMedia({
1488
+ fileId: mediaItem.id,
1489
+ fileName: mediaItem.name,
1490
+ extension: mediaItem.extension,
1491
+ webhookUrl,
1492
+ bot,
1493
+ dialogId: conversation.dialogId,
1494
+ }))).filter(Boolean);
1495
+ if (downloadedMedia.length > 0) {
1496
+ mediaFields = {
1497
+ MediaPath: downloadedMedia[0].path,
1498
+ MediaType: downloadedMedia[0].contentType,
1499
+ MediaUrl: downloadedMedia[0].path,
1500
+ MediaPaths: downloadedMedia.map((mediaItem) => mediaItem.path),
1501
+ MediaUrls: downloadedMedia.map((mediaItem) => mediaItem.path),
1502
+ MediaTypes: downloadedMedia.map((mediaItem) => mediaItem.contentType),
1503
+ };
1504
+ }
1505
+ else {
1506
+ const fileNames = msgCtx.media.map((mediaItem) => mediaItem.name).join(', ');
1507
+ logger.warn('All media downloads failed, notifying user', { fileNames });
1508
+ recordHistory();
1509
+ await sendService.sendText(sendCtx, mediaDownloadFailed(msgCtx.language, fileNames));
1510
+ return;
1511
+ }
1512
+ }
1513
+ else {
1514
+ await notifyStatus(sendService, sendCtx, config, 'IMBOT_AGENT_ACTION_ANALYZING');
1515
+ replyStatusHeartbeat.holdFor(PHASE_STATUS_DURATION_SECONDS);
1516
+ }
1517
+ // Use placeholder body for media-only messages
1518
+ let body = msgCtx.text;
1519
+ if (!body && msgCtx.media.length > 0) {
1520
+ const hasImage = downloadedMedia.some((mediaItem) => mediaItem.contentType.startsWith('image/'))
1521
+ || msgCtx.media.some((mediaItem) => mediaItem.type === 'image');
1522
+ body = hasImage ? '<media:image>' : '<media:document>';
1523
+ }
1524
+ const previousEntries = historyCache.get(historyKey, historyLimit);
1525
+ const replyEntry = historyCache.findByMessageId(historyKey, msgCtx.replyToMessageId)
1526
+ ?? await hydrateReplyEntry(msgCtx.replyToMessageId);
1527
+ const bodyWithReply = formatReplyContext({
1528
+ body,
1529
+ replyEntry,
1530
+ });
1531
+ const crossChatContext = buildCrossChatMemoryContext({
1532
+ query: bodyWithReply,
1533
+ historyCache,
1534
+ });
1535
+ const bodyForAgent = crossChatContext
1536
+ ? [crossChatContext, bodyWithReply].filter(Boolean).join('\n\n')
1537
+ : bodyWithReply;
1538
+ const combinedBody = msgCtx.isGroup
1539
+ ? buildHistoryContext({
1540
+ entries: previousEntries,
1541
+ currentBody: bodyForAgent,
1542
+ })
1543
+ : bodyForAgent;
1544
+ recordHistory(body);
1545
+ // Resolve which agent handles this conversation
1546
+ const route = runtime.channel.routing.resolveAgentRoute({
1547
+ cfg,
1548
+ channel: 'bitrix24',
1549
+ accountId: ctx.accountId,
1550
+ peer: conversation.peer,
1551
+ });
1552
+ logger.debug('Resolved route', {
1553
+ sessionKey: route.sessionKey,
1554
+ agentId: route.agentId,
1555
+ matchedBy: route.matchedBy,
1556
+ });
1557
+ const inboundCtx = runtime.channel.reply.finalizeInboundContext({
1558
+ Body: combinedBody,
1559
+ BodyForAgent: bodyForAgent,
1560
+ InboundHistory: msgCtx.isGroup && previousEntries.length > 0
1561
+ ? previousEntries.map((entry) => ({
1562
+ sender: entry.sender,
1563
+ body: entry.body,
1564
+ timestamp: entry.timestamp,
1565
+ }))
1566
+ : undefined,
1567
+ RawBody: body,
1568
+ From: conversation.address,
1569
+ To: conversation.address,
1570
+ SessionKey: buildConversationSessionKey(route.sessionKey, conversation),
1571
+ AccountId: route.accountId,
1572
+ ChatType: msgCtx.isDm ? 'direct' : 'group',
1573
+ ConversationLabel: msgCtx.senderName,
1574
+ SenderName: msgCtx.senderName,
1575
+ SenderId: msgCtx.senderId,
1576
+ Provider: 'bitrix24',
1577
+ Surface: 'bitrix24',
1578
+ MessageSid: msgCtx.messageId,
1579
+ Timestamp: msgCtx.timestamp ?? Date.now(),
1580
+ ReplyToId: replyEntry?.messageId ?? msgCtx.replyToMessageId,
1581
+ ReplyToBody: replyEntry?.body,
1582
+ ReplyToSender: replyEntry?.sender,
1583
+ WasMentioned: msgCtx.wasMentioned ?? false,
1584
+ CommandAuthorized: true,
1585
+ OriginatingChannel: 'bitrix24',
1586
+ OriginatingTo: conversation.address,
1587
+ ...mediaFields,
1588
+ });
1589
+ try {
1590
+ await runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
1591
+ ctx: inboundCtx,
1592
+ cfg,
1593
+ dispatcherOptions: {
1594
+ deliver: async (payload) => {
1595
+ await replyStatusHeartbeat.stopAndWait();
1596
+ const mediaUrls = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
1597
+ if (mediaUrls.length > 0) {
1598
+ await uploadOutboundMedia({
1599
+ mediaService,
1600
+ sendCtx,
1601
+ mediaUrls,
1602
+ });
1603
+ }
1604
+ if (payload.text) {
1605
+ let text = payload.text;
1606
+ let keyboard = extractKeyboardFromPayload(payload);
1607
+ // Fallback: agent may embed button JSON in text as [[{...}]]
1608
+ if (!keyboard) {
1609
+ const extracted = extractInlineButtonsFromText(text);
1610
+ if (extracted) {
1611
+ text = extracted.cleanText;
1612
+ keyboard = extracted.keyboard;
1613
+ }
1614
+ }
1615
+ await sendService.sendText(sendCtx, text, keyboard ? { keyboard } : undefined);
1616
+ }
1617
+ },
1618
+ onReplyStart: async () => {
1619
+ await replyStatusHeartbeat.start();
1620
+ },
1621
+ onError: (err) => {
1622
+ logger.error('Error delivering reply to B24', err);
1623
+ },
1624
+ },
1625
+ });
1626
+ }
1627
+ catch (err) {
1628
+ logger.error('Error dispatching message to agent', { senderId: msgCtx.senderId, chatId: msgCtx.chatId, error: err });
1629
+ }
1630
+ finally {
1631
+ replyStatusHeartbeat.stop();
1632
+ }
1633
+ }
1634
+ finally {
1635
+ await mediaService.cleanupDownloadedMedia(downloadedMedia.map((mediaItem) => mediaItem.path));
1636
+ }
1637
+ };
1638
+ const directTextCoalescer = new BufferedDirectMessageCoalescer({
1639
+ debounceMs: DIRECT_TEXT_COALESCE_DEBOUNCE_MS,
1640
+ maxWaitMs: DIRECT_TEXT_COALESCE_MAX_WAIT_MS,
1641
+ onFlush: processAllowedMessage,
1642
+ logger,
1643
+ });
1644
+ const maybeSendDialogNotice = async (noticeKey, sendCtx, text) => {
1645
+ const now = Date.now();
1646
+ const lastSentAt = dialogNoticeTimestamps.get(noticeKey) ?? 0;
1647
+ if ((now - lastSentAt) < ACCESS_DENIED_NOTICE_COOLDOWN_MS) {
1648
+ return;
1649
+ }
1650
+ dialogNoticeTimestamps.set(noticeKey, now);
1651
+ try {
1652
+ await sendService.sendText(sendCtx, text, { convertMarkdown: false });
1653
+ }
1654
+ catch (err) {
1655
+ logger.warn('Failed to send dialog notice', err);
1656
+ }
1657
+ };
1658
+ const maybeReactToDeniedMention = async (msgCtx) => {
1659
+ if (!msgCtx.isGroup || !msgCtx.wasMentioned) {
1660
+ return;
1661
+ }
1662
+ const messageId = toMessageId(msgCtx.messageId);
1663
+ if (!messageId) {
1664
+ return;
1665
+ }
1666
+ try {
1667
+ await api.addReaction(webhookUrl, bot, messageId, ACCESS_DENIED_REACTION);
1668
+ }
1669
+ catch (err) {
1670
+ logger.debug('Failed to add access-denied reaction', {
1671
+ chatId: msgCtx.chatId,
1672
+ messageId: msgCtx.messageId,
1673
+ error: err,
1674
+ });
1675
+ }
1676
+ };
1677
+ const maybeReactToBotMessageReaction = async (reactionCtx) => {
1678
+ if (reactionCtx.action !== 'set') {
1679
+ return;
1680
+ }
1681
+ const botId = String(bot.botId);
1682
+ if (reactionCtx.senderId === botId || reactionCtx.messageAuthorId !== botId) {
1683
+ return;
1684
+ }
1685
+ const messageId = toMessageId(reactionCtx.messageId);
1686
+ if (!messageId) {
1687
+ return;
1688
+ }
1689
+ try {
1690
+ await api.addReaction(webhookUrl, bot, messageId, BOT_MESSAGE_WATCH_REACTION);
1691
+ }
1692
+ catch (err) {
1693
+ const errMsg = err instanceof Error ? err.message : String(err);
1694
+ if (errMsg.includes('REACTION_ALREADY_SET')) {
1695
+ logger.debug('Watch reaction already set on bot message', {
1696
+ chatId: reactionCtx.dialogId,
1697
+ messageId: reactionCtx.messageId,
1698
+ });
1699
+ return;
1700
+ }
1701
+ logger.warn('Failed to add watch reaction to bot message', {
1702
+ chatId: reactionCtx.dialogId,
1703
+ messageId: reactionCtx.messageId,
1704
+ error: err,
1705
+ });
1706
+ }
1707
+ };
1708
+ const notifyWebhookOwnerAboutWatchMatch = async (msgCtx, watchRule) => {
1709
+ const ownerId = webhookOwnerId;
1710
+ const forwardedMessageId = toMessageId(msgCtx.messageId);
1711
+ if (!ownerId || !forwardedMessageId) {
1712
+ logger.warn('Skipping owner watch notification: missing owner dialog or message id', {
1713
+ ownerId,
1714
+ messageId: msgCtx.messageId,
1715
+ chatId: msgCtx.chatId,
1716
+ });
1717
+ return false;
1718
+ }
1719
+ const ownerSendCtx = {
1720
+ webhookUrl,
1721
+ bot,
1722
+ dialogId: ownerId,
1723
+ };
1724
+ const noticeText = watchOwnerDmNotice(msgCtx.language, {
1725
+ chatRef: buildChatContextUrl(msgCtx.chatId, msgCtx.messageId, msgCtx.isDm
1726
+ ? (msgCtx.senderName || msgCtx.chatName || msgCtx.chatId)
1727
+ : (msgCtx.chatName ?? msgCtx.chatId)),
1728
+ topicsRef: buildTopicsBbCode(watchRule.topics),
1729
+ sourceKind: msgCtx.isDm ? 'dm' : 'chat',
1730
+ });
1731
+ try {
1732
+ await sendService.sendText(ownerSendCtx, noticeText, {
1733
+ convertMarkdown: false,
1734
+ });
1735
+ if (msgCtx.eventScope === 'user' && msgCtx.isDm) {
1736
+ const quoteText = buildWatchQuoteText({
1737
+ senderName: msgCtx.senderName || msgCtx.chatName || msgCtx.chatId,
1738
+ language: msgCtx.language,
1739
+ timestamp: msgCtx.timestamp,
1740
+ anchor: `#${msgCtx.chatId}:${ownerId}/${msgCtx.messageId}`,
1741
+ body: msgCtx.text.trim(),
1742
+ });
1743
+ await sendService.sendText(ownerSendCtx, quoteText, {
1744
+ convertMarkdown: false,
1745
+ });
1746
+ return true;
1747
+ }
1748
+ await api.sendMessage(webhookUrl, bot, ownerId, null, { forwardMessages: [forwardedMessageId] });
1749
+ return true;
1750
+ }
1751
+ catch (err) {
1752
+ logger.warn('Failed to send owner watch notification with native forward', err);
1753
+ return false;
1754
+ }
1755
+ };
1756
+ // Register slash commands (runs in background)
1757
+ ensureCommandsRegistered(api, config, bot, logger).catch((err) => {
1758
+ logger.warn('Command registration failed', err);
1759
+ });
1760
+ if (botRegistration.isNew) {
1761
+ await sendInitialWelcomeToWebhookOwner({
1762
+ config,
1763
+ bot,
1764
+ sendService,
1765
+ language: botRegistration.language,
1766
+ welcomedDialogs,
1767
+ logger,
1768
+ });
1769
+ }
1770
+ const inboundHandler = new InboundHandler({
1771
+ config,
1772
+ logger,
1773
+ onReactionChange: async (reactionCtx) => {
1774
+ await maybeReactToBotMessageReaction(reactionCtx);
1775
+ },
1776
+ onMessage: async (msgCtx) => {
1777
+ logger.info('Inbound message', {
1778
+ senderId: msgCtx.senderId,
1779
+ chatId: msgCtx.chatId,
1780
+ messageId: msgCtx.messageId,
1781
+ textLen: msgCtx.text.length,
1782
+ });
1783
+ const pendingForwardContext = msgCtx.isForwarded
1784
+ ? directTextCoalescer.take(ctx.accountId, msgCtx.chatId)
1785
+ : null;
1786
+ const runtime = getBitrix24Runtime();
1787
+ // Pairing-aware access control
1788
+ const sendCtx = {
1789
+ webhookUrl,
1790
+ bot,
1791
+ dialogId: msgCtx.chatId,
1792
+ };
1793
+ const conversation = resolveConversationRef({
1794
+ accountId: ctx.accountId,
1795
+ dialogId: msgCtx.chatId,
1796
+ isDirect: msgCtx.isDm,
1797
+ });
1798
+ const historyKey = conversation.historyKey;
1799
+ const historyLimit = resolveHistoryLimit(config);
1800
+ const groupAccess = msgCtx.isGroup
1801
+ ? resolveGroupAccess({
1802
+ config,
1803
+ dialogId: msgCtx.chatId,
1804
+ chatId: msgCtx.chatInternalId,
1805
+ })
1806
+ : null;
1807
+ const agentWatchRules = config.agentMode && msgCtx.eventScope === 'user'
1808
+ ? resolveAgentWatchRules({
1809
+ config,
1810
+ dialogId: msgCtx.chatId,
1811
+ chatId: msgCtx.chatInternalId,
1812
+ })
1813
+ : [];
1814
+ const watchRule = msgCtx.isGroup && groupAccess?.groupAllowed
1815
+ ? findMatchingWatchRule(msgCtx, groupAccess?.watch)
1816
+ : undefined;
1817
+ const activeWatchRule = watchRule?.mode === 'notifyOwnerDm'
1818
+ && webhookOwnerId
1819
+ && msgCtx.senderId === webhookOwnerId
1820
+ ? undefined
1821
+ : watchRule;
1822
+ const agentWatchRule = msgCtx.eventScope === 'user'
1823
+ ? findMatchingWatchRule(msgCtx, agentWatchRules)
1824
+ : undefined;
1825
+ /** Shorthand: record message in RAM history for this dialog. */
1826
+ const recordHistory = (body) => appendMessageToHistory({ historyCache, historyKey, historyLimit, msgCtx, body });
1827
+ if (msgCtx.eventScope === 'user') {
1828
+ const isBotDialogUserEvent = msgCtx.isDm && msgCtx.chatId === String(bot.botId);
1829
+ const isBotAuthoredUserEvent = msgCtx.senderId === String(bot.botId);
1830
+ if (isBotDialogUserEvent || isBotAuthoredUserEvent) {
1831
+ logger.debug('Skipping agent-mode user event for bot-owned conversation', {
1832
+ senderId: msgCtx.senderId,
1833
+ chatId: msgCtx.chatId,
1834
+ messageId: msgCtx.messageId,
1835
+ isBotDialogUserEvent,
1836
+ isBotAuthoredUserEvent,
1837
+ });
1838
+ return;
1839
+ }
1840
+ recordHistory();
1841
+ if (webhookOwnerId && msgCtx.senderId !== webhookOwnerId && agentWatchRule?.mode === 'notifyOwnerDm') {
1842
+ await notifyWebhookOwnerAboutWatchMatch(msgCtx, agentWatchRule);
1843
+ logger.debug('User-event watch matched and notified webhook owner in DM', {
1844
+ senderId: msgCtx.senderId,
1845
+ chatId: msgCtx.chatId,
1846
+ messageId: msgCtx.messageId,
1847
+ });
1848
+ }
1849
+ return;
1850
+ }
1851
+ if (msgCtx.isGroup && groupAccess?.groupPolicy === 'disabled') {
1852
+ logger.info('Group chat is disabled by policy, leaving chat', {
1853
+ chatId: msgCtx.chatId,
1854
+ senderId: msgCtx.senderId,
1855
+ });
1856
+ try {
1857
+ await sendService.sendText(sendCtx, groupChatUnsupported(msgCtx.language));
1858
+ await api.leaveChat(webhookUrl, bot, msgCtx.chatId);
1859
+ }
1860
+ catch (err) {
1861
+ logger.error('Failed to leave disabled group chat', err);
1862
+ }
1863
+ return;
1864
+ }
1865
+ await sendService.markRead(sendCtx, toMessageId(msgCtx.messageId));
1866
+ if (msgCtx.isGroup
1867
+ && !activeWatchRule
1868
+ && groupAccess?.requireMention
1869
+ && !msgCtx.wasMentioned) {
1870
+ recordHistory();
1871
+ logger.info('Skipping group message without mention', {
1872
+ chatId: msgCtx.chatId,
1873
+ senderId: msgCtx.senderId,
1874
+ messageId: msgCtx.messageId,
1875
+ });
1876
+ return;
1877
+ }
1878
+ if (activeWatchRule?.mode === 'notifyOwnerDm') {
1879
+ recordHistory();
1880
+ await notifyWebhookOwnerAboutWatchMatch(msgCtx, activeWatchRule);
1881
+ logger.debug('Group watch matched and notified webhook owner in DM', {
1882
+ senderId: msgCtx.senderId,
1883
+ chatId: msgCtx.chatId,
1884
+ messageId: msgCtx.messageId,
1885
+ });
1886
+ return;
1887
+ }
1888
+ const accessResult = activeWatchRule
1889
+ ? 'allow'
1890
+ : msgCtx.isGroup
1891
+ ? await checkGroupAccessWithPairing({
1892
+ senderId: msgCtx.senderId,
1893
+ dialogId: msgCtx.chatId,
1894
+ chatId: msgCtx.chatInternalId,
1895
+ config,
1896
+ runtime,
1897
+ accountId: ctx.accountId,
1898
+ pairingAdapter: bitrix24Plugin.pairing,
1899
+ logger,
1900
+ })
1901
+ : await checkAccessWithPairing({
1902
+ senderId: msgCtx.senderId,
1903
+ dialogId: msgCtx.chatId,
1904
+ isDirect: msgCtx.isDm,
1905
+ config,
1906
+ runtime,
1907
+ accountId: ctx.accountId,
1908
+ pairingAdapter: bitrix24Plugin.pairing,
1909
+ sendReply: async (text) => {
1910
+ await sendService.sendText(sendCtx, text, { convertMarkdown: false });
1911
+ },
1912
+ logger,
1913
+ });
1914
+ if (accessResult === 'deny') {
1915
+ if (msgCtx.isGroup) {
1916
+ recordHistory();
1917
+ if (!msgCtx.wasMentioned) {
1918
+ logger.debug('Group message blocked silently without mention', {
1919
+ senderId: msgCtx.senderId,
1920
+ chatId: msgCtx.chatId,
1921
+ messageId: msgCtx.messageId,
1922
+ });
1923
+ return;
1924
+ }
1925
+ }
1926
+ await maybeReactToDeniedMention(msgCtx);
1927
+ const noticeKey = msgCtx.isDm
1928
+ ? `deny:${msgCtx.chatId}`
1929
+ : `group-deny:${msgCtx.chatId}:${msgCtx.senderId}`;
1930
+ const policy = msgCtx.isDm
1931
+ ? config.dmPolicy
1932
+ : groupAccess?.groupPolicy;
1933
+ await maybeSendDialogNotice(noticeKey, sendCtx, buildAccessDeniedNotice(msgCtx.language, policy, {
1934
+ hasAllowList: msgCtx.isDm
1935
+ ? normalizeAllowList(config.allowFrom).length > 0
1936
+ : Boolean(groupAccess?.senderAllowFrom.length),
1937
+ }));
1938
+ logger.debug('Message blocked (deny)', { senderId: msgCtx.senderId, chatId: msgCtx.chatId });
1939
+ return;
1940
+ }
1941
+ if (accessResult === 'pairing') {
1942
+ if (msgCtx.isGroup) {
1943
+ recordHistory();
1944
+ await maybeSendDialogNotice(`group-pairing:${msgCtx.chatId}:${msgCtx.senderId}`, sendCtx, groupPairingPending(msgCtx.language));
1945
+ }
1946
+ logger.debug(`Message blocked (${accessResult})`, { senderId: msgCtx.senderId });
1947
+ return;
1948
+ }
1949
+ await sendService.sendTyping(sendCtx);
1950
+ if (msgCtx.isForwarded) {
1951
+ if (pendingForwardContext) {
1952
+ const hydratedForwardContext = await hydrateForwardedMessageContext(msgCtx);
1953
+ const mergedForwardContext = mergeForwardedMessageContext(pendingForwardContext, hydratedForwardContext);
1954
+ await processAllowedMessage(mergedForwardContext);
1955
+ return;
1956
+ }
1957
+ logger.info('Hydrating forwarded message context from Bitrix24 API', {
1958
+ senderId: msgCtx.senderId,
1959
+ chatId: msgCtx.chatId,
1960
+ messageId: msgCtx.messageId,
1961
+ });
1962
+ await processAllowedMessage(await hydrateForwardedMessageContext(msgCtx));
1963
+ return;
1964
+ }
1965
+ if (canCoalesceDirectMessage(msgCtx, config)) {
1966
+ directTextCoalescer.enqueue(ctx.accountId, msgCtx);
1967
+ return;
1968
+ }
1969
+ await directTextCoalescer.flush(ctx.accountId, msgCtx.chatId);
1970
+ await processAllowedMessage(msgCtx);
1971
+ },
1972
+ onCommand: async (cmdCtx) => {
1973
+ const { commandId, commandName, commandParams, commandText, senderId, dialogId, chatId, chatType, messageId, } = cmdCtx;
1974
+ const isDm = chatType === 'P';
1975
+ const conversation = resolveConversationRef({
1976
+ accountId: ctx.accountId,
1977
+ dialogId,
1978
+ isDirect: isDm,
1979
+ });
1980
+ logger.info('Inbound command', {
1981
+ commandId,
1982
+ commandName,
1983
+ commandParams,
1984
+ commandText,
1985
+ senderId,
1986
+ dialogId,
1987
+ chatId,
1988
+ conversationDialogId: conversation.dialogId,
1989
+ });
1990
+ const sendCtx = {
1991
+ webhookUrl,
1992
+ bot,
1993
+ dialogId: conversation.dialogId,
1994
+ };
1995
+ let runtime;
1996
+ let cfg;
1997
+ try {
1998
+ runtime = getBitrix24Runtime();
1999
+ cfg = runtime.config.loadConfig();
2000
+ }
2001
+ catch (err) {
2002
+ logger.error('Failed to get runtime/config for command', err);
2003
+ return;
2004
+ }
2005
+ const commandMessageId = toMessageId(messageId);
2006
+ const commandSendCtx = commandMessageId
2007
+ ? {
2008
+ ...sendCtx,
2009
+ commandId,
2010
+ messageId: commandMessageId,
2011
+ commandDialogId: dialogId,
2012
+ }
2013
+ : null;
2014
+ const groupAccess = !isDm
2015
+ ? resolveGroupAccess({
2016
+ config,
2017
+ dialogId,
2018
+ chatId,
2019
+ })
2020
+ : null;
2021
+ // Access control
2022
+ let accessResult;
2023
+ try {
2024
+ accessResult = !isDm
2025
+ ? await checkGroupAccessWithPairing({
2026
+ senderId,
2027
+ dialogId,
2028
+ chatId,
2029
+ config,
2030
+ runtime,
2031
+ accountId: ctx.accountId,
2032
+ pairingAdapter: bitrix24Plugin.pairing,
2033
+ logger,
2034
+ })
2035
+ : await checkAccessWithPairing({
2036
+ senderId,
2037
+ dialogId,
2038
+ isDirect: isDm,
2039
+ config,
2040
+ runtime,
2041
+ accountId: ctx.accountId,
2042
+ pairingAdapter: bitrix24Plugin.pairing,
2043
+ sendReply: async (text) => {
2044
+ if (commandSendCtx) {
2045
+ await sendService.answerCommandText(commandSendCtx, text, { convertMarkdown: false });
2046
+ return;
2047
+ }
2048
+ await sendService.sendText(sendCtx, text, { convertMarkdown: false });
2049
+ },
2050
+ logger,
2051
+ });
2052
+ }
2053
+ catch (err) {
2054
+ logger.error('Access check failed for command', err);
2055
+ return;
2056
+ }
2057
+ if (!commandMessageId || !commandSendCtx) {
2058
+ logger.warn('Command event has invalid messageId, skipping response', { commandId, messageId, dialogId });
2059
+ return;
2060
+ }
2061
+ if (!isDm && groupAccess?.groupPolicy === 'disabled') {
2062
+ await sendService.answerCommandText(commandSendCtx, groupChatUnsupported(cmdCtx.language), { convertMarkdown: false });
2063
+ return;
2064
+ }
2065
+ await sendService.sendStatus(sendCtx, 'IMBOT_AGENT_ACTION_THINKING', 8);
2066
+ if (accessResult === 'deny') {
2067
+ await sendService.markRead(sendCtx, commandMessageId);
2068
+ await sendService.answerCommandText(commandSendCtx, buildAccessDeniedNotice(cmdCtx.language, isDm ? config.dmPolicy : groupAccess?.groupPolicy, {
2069
+ hasAllowList: isDm
2070
+ ? normalizeAllowList(config.allowFrom).length > 0
2071
+ : Boolean(groupAccess?.senderAllowFrom.length),
2072
+ }), { convertMarkdown: false });
2073
+ logger.debug('Command blocked (deny)', { senderId, dialogId });
2074
+ return;
2075
+ }
2076
+ await sendService.markRead(sendCtx, commandMessageId);
2077
+ if (accessResult === 'pairing') {
2078
+ if (!isDm) {
2079
+ await sendService.answerCommandText(commandSendCtx, groupPairingPending(cmdCtx.language), { convertMarkdown: false });
2080
+ }
2081
+ logger.debug(`Command blocked (${accessResult})`, { senderId });
2082
+ return;
2083
+ }
2084
+ await directTextCoalescer.flush(ctx.accountId, conversation.dialogId);
2085
+ if (commandName === 'help' || commandName === 'commands') {
2086
+ await sendService.answerCommandText(commandSendCtx, buildCommandsHelpText(cmdCtx.language, { concise: commandName === 'help' }), { keyboard: DEFAULT_COMMAND_KEYBOARD, convertMarkdown: false });
2087
+ return;
2088
+ }
2089
+ const route = runtime.channel.routing.resolveAgentRoute({
2090
+ cfg,
2091
+ channel: 'bitrix24',
2092
+ accountId: ctx.accountId,
2093
+ peer: conversation.peer,
2094
+ });
2095
+ const slashSessionKey = `bitrix24:slash:${senderId}:${Date.now()}`;
2096
+ const inboundCtx = runtime.channel.reply.finalizeInboundContext({
2097
+ Body: commandText,
2098
+ BodyForAgent: commandText,
2099
+ RawBody: commandText,
2100
+ CommandBody: commandText,
2101
+ CommandAuthorized: true,
2102
+ CommandSource: 'native',
2103
+ CommandTargetSessionKey: buildConversationSessionKey(route.sessionKey, conversation),
2104
+ From: conversation.address,
2105
+ To: `slash:${senderId}`,
2106
+ SessionKey: slashSessionKey,
2107
+ AccountId: route.accountId,
2108
+ ChatType: isDm ? 'direct' : 'group',
2109
+ ConversationLabel: senderId,
2110
+ SenderName: senderId,
2111
+ SenderId: senderId,
2112
+ Provider: 'bitrix24',
2113
+ Surface: 'bitrix24',
2114
+ MessageSid: messageId,
2115
+ Timestamp: Date.now(),
2116
+ WasMentioned: true,
2117
+ OriginatingChannel: 'bitrix24',
2118
+ OriginatingTo: conversation.address,
2119
+ });
2120
+ const replyStatusHeartbeat = createReplyStatusHeartbeat({
2121
+ sendService,
2122
+ sendCtx,
2123
+ config,
2124
+ });
2125
+ let commandReplyDelivered = false;
2126
+ try {
2127
+ await replyStatusHeartbeat.start();
2128
+ await runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
2129
+ ctx: inboundCtx,
2130
+ cfg,
2131
+ dispatcherOptions: {
2132
+ deliver: async (payload) => {
2133
+ await replyStatusHeartbeat.stopAndWait();
2134
+ if (payload.text) {
2135
+ const keyboard = extractKeyboardFromPayload(payload) ?? DEFAULT_COMMAND_KEYBOARD;
2136
+ const formattedPayload = normalizeCommandReplyPayload({
2137
+ commandName,
2138
+ commandParams,
2139
+ text: payload.text,
2140
+ language: cmdCtx.language,
2141
+ });
2142
+ if (!commandReplyDelivered) {
2143
+ commandReplyDelivered = true;
2144
+ await sendService.answerCommandText(commandSendCtx, formattedPayload.text, {
2145
+ keyboard,
2146
+ convertMarkdown: formattedPayload.convertMarkdown,
2147
+ });
2148
+ return;
2149
+ }
2150
+ await sendService.sendText(sendCtx, formattedPayload.text, {
2151
+ keyboard,
2152
+ convertMarkdown: formattedPayload.convertMarkdown,
2153
+ });
2154
+ }
2155
+ },
2156
+ onReplyStart: async () => {
2157
+ await replyStatusHeartbeat.start();
2158
+ },
2159
+ onError: (err) => {
2160
+ logger.error('Error delivering command reply to B24', err);
2161
+ },
2162
+ },
2163
+ });
2164
+ }
2165
+ catch (err) {
2166
+ logger.error('Error dispatching command to agent', { commandName, senderId, dialogId, error: err });
2167
+ }
2168
+ finally {
2169
+ replyStatusHeartbeat.stop();
2170
+ }
2171
+ },
2172
+ onJoinChat: async (joinCtx) => {
2173
+ const { senderId, dialogId, chatId, chatType, language } = joinCtx;
2174
+ logger.info('Bot joined chat', { senderId, dialogId, chatId, chatType });
2175
+ if (!dialogId)
2176
+ return;
2177
+ const isGroupChat = chatType === 'chat' || chatType === 'open';
2178
+ const groupAccess = isGroupChat
2179
+ ? resolveGroupAccess({ config, dialogId, chatId })
2180
+ : null;
2181
+ if (!isGroupChat && shouldSkipJoinChatWelcome({
2182
+ dialogId,
2183
+ chatType,
2184
+ webhookUrl,
2185
+ dmPolicy: config.dmPolicy,
2186
+ })) {
2187
+ logger.info('Skipping welcome for non-owner dialog in webhookUser mode', { dialogId });
2188
+ return;
2189
+ }
2190
+ const sendCtx = {
2191
+ webhookUrl,
2192
+ bot,
2193
+ dialogId,
2194
+ };
2195
+ if (isGroupChat && (!groupAccess?.groupAllowed || groupAccess.groupPolicy === 'disabled')) {
2196
+ logger.info('Group chat blocked by policy, leaving', { dialogId });
2197
+ try {
2198
+ await sendService.sendText(sendCtx, groupChatUnsupported(language));
2199
+ await api.leaveChat(webhookUrl, bot, dialogId);
2200
+ }
2201
+ catch (err) {
2202
+ logger.error('Failed to leave group chat', err);
2203
+ }
2204
+ return;
2205
+ }
2206
+ if (isGroupChat) {
2207
+ const runtime = getBitrix24Runtime();
2208
+ const inviterAccessResult = await checkGroupAccessPassive({
2209
+ senderId,
2210
+ dialogId,
2211
+ chatId,
2212
+ config,
2213
+ runtime,
2214
+ accountId: ctx.accountId,
2215
+ logger,
2216
+ });
2217
+ if (inviterAccessResult !== 'allow') {
2218
+ const noticeText = inviterAccessResult === 'pairing'
2219
+ ? groupPairingPending(language)
2220
+ : buildAccessDeniedNotice(language, groupAccess?.groupPolicy, {
2221
+ hasAllowList: Boolean(groupAccess?.senderAllowFrom.length),
2222
+ });
2223
+ logger.info('Leaving group chat invited by user without access', {
2224
+ dialogId,
2225
+ chatId,
2226
+ senderId,
2227
+ inviterAccessResult,
2228
+ });
2229
+ try {
2230
+ await sendService.sendText(sendCtx, noticeText, { convertMarkdown: false });
2231
+ await api.leaveChat(webhookUrl, bot, dialogId);
2232
+ }
2233
+ catch (err) {
2234
+ logger.error('Failed to leave group chat after inviter access check', err);
2235
+ }
2236
+ return;
2237
+ }
2238
+ logger.info('Group chat enabled by policy, skipping auto-welcome', { dialogId });
2239
+ return;
2240
+ }
2241
+ if (welcomedDialogs.has(dialogId)) {
2242
+ logger.info('Skipping duplicate welcome for already welcomed dialog', { dialogId });
2243
+ return;
2244
+ }
2245
+ // Send welcome message
2246
+ const isPairing = config.dmPolicy === 'pairing';
2247
+ const text = onboardingMessage(language, config.botName ?? 'OpenClaw', config.dmPolicy);
2248
+ try {
2249
+ await sendService.sendText(sendCtx, text, isPairing ? undefined : { keyboard: DEFAULT_COMMAND_KEYBOARD });
2250
+ welcomedDialogs.add(dialogId);
2251
+ logger.info('Welcome message sent', { dialogId });
2252
+ }
2253
+ catch (err) {
2254
+ logger.error('Failed to send welcome message', err);
2255
+ }
2256
+ },
2257
+ onBotDelete: async (_data) => {
2258
+ logger.info('Bot deleted from portal');
2259
+ },
2260
+ });
2261
+ gatewayState = { accountId: ctx.accountId, api, bot, sendService, mediaService, inboundHandler, eventMode };
2262
+ logger.info(`[${ctx.accountId}] Bitrix24 channel started (${eventMode} mode)`);
2263
+ // ─── Mode-specific lifecycle ──────────────────────────────────
2264
+ if (eventMode === 'fetch') {
2265
+ // FETCH mode: start polling loop (blocks until abort)
2266
+ const pollingService = new PollingService({
2267
+ api,
2268
+ webhookUrl,
2269
+ bot,
2270
+ accountId: ctx.accountId,
2271
+ pollingIntervalMs: config.pollingIntervalMs ?? 3000,
2272
+ pollingFastIntervalMs: config.pollingFastIntervalMs ?? 100,
2273
+ withUserEvents: Boolean(config.agentMode),
2274
+ onEvent: async (event) => {
2275
+ const fetchCtx = {
2276
+ webhookUrl,
2277
+ botId: bot.botId,
2278
+ botToken: bot.botToken,
2279
+ };
2280
+ await inboundHandler.handleFetchEvent(event, fetchCtx);
2281
+ },
2282
+ abortSignal: ctx.abortSignal,
2283
+ logger,
2284
+ });
2285
+ try {
2286
+ await pollingService.start();
2287
+ }
2288
+ finally {
2289
+ logger.info(`[${ctx.accountId}] Bitrix24 channel stopping (fetch)`);
2290
+ clearInterval(deniedCleanupTimer);
2291
+ await directTextCoalescer.flushAll();
2292
+ directTextCoalescer.destroy();
2293
+ inboundHandler.destroy();
2294
+ api.destroy();
2295
+ gatewayState = null;
2296
+ }
2297
+ }
2298
+ else {
2299
+ // WEBHOOK mode: keep alive until abort signal
2300
+ return new Promise((resolve) => {
2301
+ ctx.abortSignal.addEventListener('abort', () => {
2302
+ const cleanup = async () => {
2303
+ logger.info(`[${ctx.accountId}] Bitrix24 channel stopping (webhook)`);
2304
+ clearInterval(deniedCleanupTimer);
2305
+ // Flush with timeout to prevent hanging
2306
+ await Promise.race([
2307
+ directTextCoalescer.flushAll(),
2308
+ new Promise((r) => setTimeout(r, 5000)),
2309
+ ]);
2310
+ directTextCoalescer.destroy();
2311
+ inboundHandler.destroy();
2312
+ api.destroy();
2313
+ gatewayState = null;
2314
+ };
2315
+ cleanup().catch((err) => {
2316
+ logger.error('Webhook cleanup error', err);
2317
+ }).finally(() => resolve());
2318
+ });
2319
+ });
2320
+ }
2321
+ },
2322
+ },
2323
+ };
2324
+ //# sourceMappingURL=channel.js.map