@ihazz/bitrix24 0.2.5 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +118 -164
- package/index.ts +46 -11
- package/openclaw.plugin.json +1 -0
- package/package.json +1 -1
- package/skills/bitrix24/SKILL.md +70 -0
- package/src/access-control.ts +102 -48
- package/src/api.ts +434 -232
- package/src/channel.ts +1441 -365
- package/src/commands.ts +169 -31
- package/src/config-schema.ts +8 -3
- package/src/config.ts +11 -0
- package/src/dedup.ts +4 -0
- package/src/i18n.ts +127 -0
- package/src/inbound-handler.ts +306 -110
- package/src/media-service.ts +218 -65
- package/src/message-utils.ts +252 -10
- package/src/polling-service.ts +240 -0
- package/src/rate-limiter.ts +11 -6
- package/src/send-service.ts +140 -60
- package/src/types.ts +279 -185
- package/src/utils.ts +54 -3
- package/tests/access-control.test.ts +174 -58
- package/tests/api.test.ts +95 -0
- package/tests/channel.test.ts +230 -9
- package/tests/commands.test.ts +57 -0
- package/tests/config.test.ts +5 -1
- package/tests/i18n.test.ts +47 -0
- package/tests/inbound-handler.test.ts +554 -69
- package/tests/index.test.ts +94 -0
- package/tests/media-service.test.ts +146 -51
- package/tests/message-utils.test.ts +64 -0
- package/tests/polling-service.test.ts +77 -0
- package/tests/rate-limiter.test.ts +2 -2
- package/tests/send-service.test.ts +145 -0
package/src/channel.ts
CHANGED
|
@@ -3,57 +3,515 @@ import { basename } from 'node:path';
|
|
|
3
3
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
4
4
|
import { listAccountIds, resolveAccount, getConfig } from './config.js';
|
|
5
5
|
import { Bitrix24Api } from './api.js';
|
|
6
|
+
import type { BotContext } from './api.js';
|
|
6
7
|
import { SendService } from './send-service.js';
|
|
8
|
+
import type { CommandSendContext, SendContext } from './send-service.js';
|
|
7
9
|
import { MediaService } from './media-service.js';
|
|
8
10
|
import type { DownloadedMedia } from './media-service.js';
|
|
9
11
|
import { InboundHandler } from './inbound-handler.js';
|
|
10
|
-
import {
|
|
12
|
+
import type { FetchCommandContext, FetchJoinChatContext } from './inbound-handler.js';
|
|
13
|
+
import { PollingService } from './polling-service.js';
|
|
14
|
+
import {
|
|
15
|
+
normalizeAllowEntry,
|
|
16
|
+
normalizeAllowList,
|
|
17
|
+
checkAccessWithPairing,
|
|
18
|
+
getWebhookUserId,
|
|
19
|
+
} from './access-control.js';
|
|
11
20
|
import { DEFAULT_AVATAR_BASE64 } from './bot-avatar.js';
|
|
12
|
-
import { defaultLogger } from './utils.js';
|
|
21
|
+
import { Bitrix24ApiError, defaultLogger, CHANNEL_PREFIX_RE } from './utils.js';
|
|
13
22
|
import { getBitrix24Runtime } from './runtime.js';
|
|
14
23
|
import type { ChannelPairingAdapter } from './runtime.js';
|
|
15
|
-
import { OPENCLAW_COMMANDS } from './commands.js';
|
|
24
|
+
import { OPENCLAW_COMMANDS, buildCommandsHelpText, formatModelsCommandReply } from './commands.js';
|
|
25
|
+
import {
|
|
26
|
+
forwardedMessageUnsupported,
|
|
27
|
+
mediaDownloadFailed,
|
|
28
|
+
groupChatUnsupported,
|
|
29
|
+
onboardingMessage,
|
|
30
|
+
personalBotOwnerOnly,
|
|
31
|
+
replyMessageUnsupported,
|
|
32
|
+
} from './i18n.js';
|
|
16
33
|
import type {
|
|
17
34
|
B24MsgContext,
|
|
18
|
-
|
|
19
|
-
|
|
35
|
+
B24InputActionStatusCode,
|
|
36
|
+
B24V2FetchEventItem,
|
|
37
|
+
B24V2DeleteEventData,
|
|
38
|
+
FetchContext,
|
|
20
39
|
Bitrix24AccountConfig,
|
|
21
40
|
B24Keyboard,
|
|
22
41
|
KeyboardButton,
|
|
42
|
+
Logger,
|
|
23
43
|
} from './types.js';
|
|
24
44
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
45
|
+
const PHASE_STATUS_DURATION_SECONDS = 8;
|
|
46
|
+
const PHASE_STATUS_REFRESH_GRACE_MS = 1000;
|
|
47
|
+
const THINKING_STATUS_DURATION_SECONDS = 30;
|
|
48
|
+
const THINKING_STATUS_REFRESH_GRACE_MS = 6000;
|
|
49
|
+
const DIRECT_TEXT_COALESCE_DEBOUNCE_MS = 200;
|
|
50
|
+
const DIRECT_TEXT_COALESCE_MAX_WAIT_MS = 5000;
|
|
51
|
+
const ACCESS_DENIED_NOTICE_COOLDOWN_MS = 60000;
|
|
52
|
+
const AUTO_BOT_CODE_MAX_CANDIDATES = 100;
|
|
53
|
+
const MEDIA_DOWNLOAD_CONCURRENCY = 2;
|
|
54
|
+
const MAX_WEBHOOK_BODY_BYTES = 1024 * 1024;
|
|
55
|
+
|
|
56
|
+
// ─── Emoji → B24 reaction code mapping ──────────────────────────────────
|
|
57
|
+
// B24 uses named reaction codes, not Unicode emoji.
|
|
58
|
+
// Map common Unicode emoji to their B24 equivalents.
|
|
59
|
+
const EMOJI_TO_B24_REACTION: Record<string, string> = {
|
|
60
|
+
'👍': 'like',
|
|
61
|
+
'👎': 'dislike',
|
|
62
|
+
'😂': 'faceWithTearsOfJoy',
|
|
63
|
+
'❤️': 'redHeart',
|
|
64
|
+
'❤': 'redHeart',
|
|
65
|
+
'😐': 'neutralFace',
|
|
66
|
+
'🔥': 'fire',
|
|
67
|
+
'😢': 'cry',
|
|
68
|
+
'🙂': 'slightlySmilingFace',
|
|
69
|
+
'😉': 'winkingFace',
|
|
70
|
+
'😆': 'laugh',
|
|
71
|
+
'😘': 'kiss',
|
|
72
|
+
'😲': 'wonder',
|
|
73
|
+
'🙁': 'slightlyFrowningFace',
|
|
74
|
+
'😭': 'loudlyCryingFace',
|
|
75
|
+
'😛': 'faceWithStuckOutTongue',
|
|
76
|
+
'😜': 'faceWithStuckOutTongueAndWinkingEye',
|
|
77
|
+
'😎': 'smilingFaceWithSunglasses',
|
|
78
|
+
'😕': 'confusedFace',
|
|
79
|
+
'😳': 'flushedFace',
|
|
80
|
+
'🤔': 'thinkingFace',
|
|
81
|
+
'😠': 'angry',
|
|
82
|
+
'😈': 'smilingFaceWithHorns',
|
|
83
|
+
'🤒': 'faceWithThermometer',
|
|
84
|
+
'🤦': 'facepalm',
|
|
85
|
+
'💩': 'poo',
|
|
86
|
+
'💪': 'flexedBiceps',
|
|
87
|
+
'👏': 'clappingHands',
|
|
88
|
+
'🖐️': 'raisedHand',
|
|
89
|
+
'🖐': 'raisedHand',
|
|
90
|
+
'😍': 'smilingFaceWithHeartEyes',
|
|
91
|
+
'🥰': 'smilingFaceWithHearts',
|
|
92
|
+
'🥺': 'pleadingFace',
|
|
93
|
+
'😌': 'relievedFace',
|
|
94
|
+
'🙏': 'foldedHands',
|
|
95
|
+
'👌': 'okHand',
|
|
96
|
+
'🤘': 'signHorns',
|
|
97
|
+
'🤟': 'loveYouGesture',
|
|
98
|
+
'🤡': 'clownFace',
|
|
99
|
+
'🥳': 'partyingFace',
|
|
100
|
+
'❓': 'questionMark',
|
|
101
|
+
'❗': 'exclamationMark',
|
|
102
|
+
'💡': 'lightBulb',
|
|
103
|
+
'💣': 'bomb',
|
|
104
|
+
'💤': 'sleepingSymbol',
|
|
105
|
+
'❌': 'crossMark',
|
|
106
|
+
'✅': 'whiteHeavyCheckMark',
|
|
107
|
+
'👀': 'eyes',
|
|
108
|
+
'🤝': 'handshake',
|
|
109
|
+
'💯': 'hundredPoints',
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
// All valid B24 reaction codes (for pass-through when code is used directly)
|
|
113
|
+
const B24_REACTION_CODES = new Set(Object.values(EMOJI_TO_B24_REACTION));
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Resolve an emoji or B24 reaction code to a valid B24 reaction code.
|
|
117
|
+
* Returns null if the emoji/code is not supported.
|
|
118
|
+
*/
|
|
119
|
+
function resolveB24Reaction(emojiOrCode: string): string | null {
|
|
120
|
+
const trimmed = emojiOrCode.trim();
|
|
121
|
+
if (B24_REACTION_CODES.has(trimmed)) return trimmed;
|
|
122
|
+
return EMOJI_TO_B24_REACTION[trimmed] ?? null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function toMessageId(value: string | number | undefined): number | undefined {
|
|
126
|
+
const parsed = Number(value);
|
|
127
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function notifyStatus(
|
|
131
|
+
sendService: SendService,
|
|
132
|
+
sendCtx: SendContext,
|
|
133
|
+
config: Bitrix24AccountConfig,
|
|
134
|
+
statusMessageCode: B24InputActionStatusCode,
|
|
135
|
+
duration = PHASE_STATUS_DURATION_SECONDS,
|
|
136
|
+
): Promise<void> {
|
|
137
|
+
if (config.showTyping === false) return;
|
|
138
|
+
await sendService.sendStatus(sendCtx, statusMessageCode, duration);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function createReplyStatusHeartbeat(params: {
|
|
142
|
+
sendService: SendService;
|
|
143
|
+
sendCtx: SendContext;
|
|
144
|
+
config: Bitrix24AccountConfig;
|
|
145
|
+
}): {
|
|
146
|
+
start: () => Promise<void>;
|
|
147
|
+
stop: () => void;
|
|
148
|
+
stopAndWait: () => Promise<void>;
|
|
149
|
+
holdFor: (durationSeconds: number, graceMs?: number) => void;
|
|
150
|
+
} {
|
|
151
|
+
const { sendService, sendCtx, config } = params;
|
|
152
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
153
|
+
let stopped = false;
|
|
154
|
+
let inFlight = false;
|
|
155
|
+
let activeTick: Promise<void> | null = null;
|
|
156
|
+
let nextHeartbeatAt = Date.now();
|
|
157
|
+
|
|
158
|
+
const scheduleNext = (): void => {
|
|
159
|
+
if (stopped || config.showTyping === false) return;
|
|
160
|
+
if (timer) {
|
|
161
|
+
clearTimeout(timer);
|
|
162
|
+
timer = null;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const delay = Math.max(0, nextHeartbeatAt - Date.now());
|
|
166
|
+
timer = setTimeout(() => {
|
|
167
|
+
void runTick();
|
|
168
|
+
}, delay);
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
const holdFor = (
|
|
172
|
+
durationSeconds: number,
|
|
173
|
+
graceMs = PHASE_STATUS_REFRESH_GRACE_MS,
|
|
174
|
+
): void => {
|
|
175
|
+
const holdMs = Math.max(0, (durationSeconds * 1000) - graceMs);
|
|
176
|
+
const holdUntil = Date.now() + holdMs;
|
|
177
|
+
if (holdUntil > nextHeartbeatAt) {
|
|
178
|
+
nextHeartbeatAt = holdUntil;
|
|
179
|
+
}
|
|
180
|
+
if (timer) {
|
|
181
|
+
scheduleNext();
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
const runTick = (): Promise<void> => {
|
|
186
|
+
const tick = (async (): Promise<void> => {
|
|
187
|
+
if (stopped || inFlight || config.showTyping === false) return;
|
|
188
|
+
if (Date.now() < nextHeartbeatAt) {
|
|
189
|
+
scheduleNext();
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
inFlight = true;
|
|
194
|
+
try {
|
|
195
|
+
await notifyStatus(
|
|
196
|
+
sendService,
|
|
197
|
+
sendCtx,
|
|
198
|
+
config,
|
|
199
|
+
'IMBOT_AGENT_ACTION_THINKING',
|
|
200
|
+
THINKING_STATUS_DURATION_SECONDS,
|
|
201
|
+
);
|
|
202
|
+
} finally {
|
|
203
|
+
inFlight = false;
|
|
204
|
+
nextHeartbeatAt = Date.now() + (
|
|
205
|
+
(THINKING_STATUS_DURATION_SECONDS * 1000) - THINKING_STATUS_REFRESH_GRACE_MS
|
|
206
|
+
);
|
|
207
|
+
scheduleNext();
|
|
208
|
+
}
|
|
209
|
+
})();
|
|
210
|
+
|
|
211
|
+
activeTick = tick;
|
|
212
|
+
void tick.finally(() => {
|
|
213
|
+
if (activeTick === tick) {
|
|
214
|
+
activeTick = null;
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
return tick;
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
return {
|
|
222
|
+
start: async () => {
|
|
223
|
+
if (stopped || timer || config.showTyping === false) return;
|
|
224
|
+
if (Date.now() >= nextHeartbeatAt) {
|
|
225
|
+
await runTick();
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
scheduleNext();
|
|
229
|
+
},
|
|
230
|
+
stop: () => {
|
|
231
|
+
stopped = true;
|
|
232
|
+
if (!timer) return;
|
|
233
|
+
clearTimeout(timer);
|
|
234
|
+
timer = null;
|
|
235
|
+
},
|
|
236
|
+
stopAndWait: async () => {
|
|
237
|
+
stopped = true;
|
|
238
|
+
if (timer) {
|
|
239
|
+
clearTimeout(timer);
|
|
240
|
+
timer = null;
|
|
241
|
+
}
|
|
242
|
+
if (activeTick) {
|
|
243
|
+
await activeTick;
|
|
244
|
+
}
|
|
245
|
+
},
|
|
246
|
+
holdFor,
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export function canCoalesceDirectMessage(
|
|
251
|
+
msgCtx: B24MsgContext,
|
|
252
|
+
config: Bitrix24AccountConfig,
|
|
253
|
+
): boolean {
|
|
254
|
+
return msgCtx.isDm
|
|
255
|
+
&& config.dmPolicy !== 'pairing'
|
|
256
|
+
&& msgCtx.media.length === 0
|
|
257
|
+
&& !msgCtx.replyToMessageId
|
|
258
|
+
&& !msgCtx.isForwarded
|
|
259
|
+
&& msgCtx.text.trim().length > 0
|
|
260
|
+
&& !msgCtx.text.trim().startsWith('/');
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export function mergeBufferedDirectMessages(messages: B24MsgContext[]): B24MsgContext {
|
|
264
|
+
const first = messages[0];
|
|
265
|
+
const last = messages[messages.length - 1];
|
|
266
|
+
|
|
267
|
+
return {
|
|
268
|
+
...first,
|
|
269
|
+
text: messages
|
|
270
|
+
.map((message) => message.text.trim())
|
|
271
|
+
.filter(Boolean)
|
|
272
|
+
.join('\n'),
|
|
273
|
+
messageId: last.messageId,
|
|
274
|
+
language: last.language ?? first.language,
|
|
275
|
+
raw: last.raw,
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export function mergeForwardedMessageContext(
|
|
280
|
+
previousMsgCtx: B24MsgContext,
|
|
281
|
+
forwardedMsgCtx: B24MsgContext,
|
|
282
|
+
): B24MsgContext {
|
|
283
|
+
const previousText = previousMsgCtx.text.trim();
|
|
284
|
+
const forwardedText = forwardedMsgCtx.text.trim();
|
|
285
|
+
const mergedText = [
|
|
286
|
+
previousText,
|
|
287
|
+
forwardedText ? `[Forwarded message]\n${forwardedText}` : '',
|
|
288
|
+
].filter(Boolean).join('\n\n');
|
|
289
|
+
|
|
290
|
+
return {
|
|
291
|
+
...forwardedMsgCtx,
|
|
292
|
+
text: mergedText,
|
|
293
|
+
media: [...previousMsgCtx.media, ...forwardedMsgCtx.media],
|
|
294
|
+
language: forwardedMsgCtx.language ?? previousMsgCtx.language,
|
|
295
|
+
isForwarded: false,
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
export function resolveDirectMessageCoalesceDelay(params: {
|
|
300
|
+
startedAt: number;
|
|
301
|
+
now: number;
|
|
302
|
+
debounceMs?: number;
|
|
303
|
+
maxWaitMs?: number;
|
|
304
|
+
}): number {
|
|
305
|
+
const debounceMs = params.debounceMs ?? DIRECT_TEXT_COALESCE_DEBOUNCE_MS;
|
|
306
|
+
const maxWaitMs = params.maxWaitMs ?? DIRECT_TEXT_COALESCE_MAX_WAIT_MS;
|
|
307
|
+
const elapsedMs = Math.max(0, params.now - params.startedAt);
|
|
308
|
+
const remainingMs = Math.max(0, maxWaitMs - elapsedMs);
|
|
309
|
+
|
|
310
|
+
return Math.min(debounceMs, remainingMs);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export function shouldSkipJoinChatWelcome(params: {
|
|
314
|
+
dialogId?: string;
|
|
315
|
+
chatType?: string;
|
|
316
|
+
webhookUrl: string;
|
|
317
|
+
dmPolicy?: Bitrix24AccountConfig['dmPolicy'];
|
|
318
|
+
}): boolean {
|
|
319
|
+
if (!params.dialogId) {
|
|
320
|
+
return false;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
if (params.chatType === 'chat' || params.chatType === 'open') {
|
|
324
|
+
return false;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const policy = params.dmPolicy ?? 'webhookUser';
|
|
328
|
+
const webhookUserId = getWebhookUserId(params.webhookUrl);
|
|
329
|
+
|
|
330
|
+
return policy === 'webhookUser'
|
|
331
|
+
&& Boolean(webhookUserId)
|
|
332
|
+
&& normalizeAllowEntry(params.dialogId) !== webhookUserId;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
async function mapWithConcurrency<T, R>(
|
|
336
|
+
items: T[],
|
|
337
|
+
concurrency: number,
|
|
338
|
+
worker: (item: T, index: number) => Promise<R>,
|
|
339
|
+
): Promise<R[]> {
|
|
340
|
+
if (items.length === 0) {
|
|
341
|
+
return [];
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const results = new Array<R>(items.length);
|
|
345
|
+
let nextIndex = 0;
|
|
346
|
+
const poolSize = Math.max(1, Math.min(concurrency, items.length));
|
|
347
|
+
|
|
348
|
+
const runners = Array.from({ length: poolSize }, async () => {
|
|
349
|
+
while (nextIndex < items.length) {
|
|
350
|
+
const currentIndex = nextIndex;
|
|
351
|
+
nextIndex += 1;
|
|
352
|
+
results[currentIndex] = await worker(items[currentIndex], currentIndex);
|
|
353
|
+
}
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
await Promise.all(runners);
|
|
357
|
+
return results;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function resolveSecurityConfig(params: {
|
|
361
|
+
cfg?: Record<string, unknown>;
|
|
362
|
+
accountId?: string;
|
|
363
|
+
account?: { config?: Record<string, unknown> };
|
|
364
|
+
}): Bitrix24AccountConfig {
|
|
365
|
+
if (params.account?.config) {
|
|
366
|
+
return params.account.config as Bitrix24AccountConfig;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
if (params.cfg) {
|
|
370
|
+
return resolveAccount(params.cfg, params.accountId).config;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
return {};
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
interface BufferedDirectMessageEntry {
|
|
377
|
+
messages: B24MsgContext[];
|
|
378
|
+
startedAt: number;
|
|
379
|
+
timer: ReturnType<typeof setTimeout>;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
class BufferedDirectMessageCoalescer {
|
|
383
|
+
private readonly entries = new Map<string, BufferedDirectMessageEntry>();
|
|
384
|
+
private readonly debounceMs: number;
|
|
385
|
+
private readonly maxWaitMs: number;
|
|
386
|
+
private readonly onFlush: (msgCtx: B24MsgContext) => Promise<void>;
|
|
387
|
+
private readonly logger: Logger;
|
|
388
|
+
private destroyed = false;
|
|
389
|
+
|
|
390
|
+
constructor(params: {
|
|
391
|
+
debounceMs: number;
|
|
392
|
+
maxWaitMs: number;
|
|
393
|
+
onFlush: (msgCtx: B24MsgContext) => Promise<void>;
|
|
394
|
+
logger: Logger;
|
|
395
|
+
}) {
|
|
396
|
+
this.debounceMs = params.debounceMs;
|
|
397
|
+
this.maxWaitMs = params.maxWaitMs;
|
|
398
|
+
this.onFlush = params.onFlush;
|
|
399
|
+
this.logger = params.logger;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
enqueue(accountId: string, msgCtx: B24MsgContext): void {
|
|
403
|
+
const key = this.getKey(accountId, msgCtx.chatId);
|
|
404
|
+
const current = this.entries.get(key);
|
|
405
|
+
|
|
406
|
+
if (current) {
|
|
407
|
+
clearTimeout(current.timer);
|
|
408
|
+
current.messages.push(msgCtx);
|
|
409
|
+
current.timer = this.createTimer(key, current.startedAt);
|
|
410
|
+
this.logger.debug('Buffered direct message appended', {
|
|
411
|
+
chatId: msgCtx.chatId,
|
|
412
|
+
bufferedCount: current.messages.length,
|
|
413
|
+
});
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
const startedAt = Date.now();
|
|
418
|
+
this.entries.set(key, {
|
|
419
|
+
messages: [msgCtx],
|
|
420
|
+
startedAt,
|
|
421
|
+
timer: this.createTimer(key, startedAt),
|
|
422
|
+
});
|
|
423
|
+
this.logger.debug('Buffered direct message started', { chatId: msgCtx.chatId });
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
async flush(accountId: string, dialogId: string): Promise<void> {
|
|
427
|
+
await this.flushKey(this.getKey(accountId, dialogId));
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
take(accountId: string, dialogId: string): B24MsgContext | null {
|
|
431
|
+
const key = this.getKey(accountId, dialogId);
|
|
432
|
+
const entry = this.entries.get(key);
|
|
433
|
+
if (!entry) return null;
|
|
434
|
+
|
|
435
|
+
clearTimeout(entry.timer);
|
|
436
|
+
this.entries.delete(key);
|
|
437
|
+
this.logger.debug('Taking buffered direct messages', {
|
|
438
|
+
chatId: dialogId,
|
|
439
|
+
bufferedCount: entry.messages.length,
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
return entry.messages.length === 1
|
|
443
|
+
? entry.messages[0]
|
|
444
|
+
: mergeBufferedDirectMessages(entry.messages);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
async flushAll(): Promise<void> {
|
|
448
|
+
for (const key of [...this.entries.keys()]) {
|
|
449
|
+
await this.flushKey(key);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
destroy(): void {
|
|
454
|
+
this.destroyed = true;
|
|
455
|
+
for (const entry of this.entries.values()) {
|
|
456
|
+
clearTimeout(entry.timer);
|
|
457
|
+
}
|
|
458
|
+
this.entries.clear();
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
private getKey(accountId: string, dialogId: string): string {
|
|
462
|
+
return `${accountId}:${dialogId}`;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
private createTimer(key: string, startedAt: number): ReturnType<typeof setTimeout> {
|
|
466
|
+
const delayMs = resolveDirectMessageCoalesceDelay({
|
|
467
|
+
startedAt,
|
|
468
|
+
now: Date.now(),
|
|
469
|
+
debounceMs: this.debounceMs,
|
|
470
|
+
maxWaitMs: this.maxWaitMs,
|
|
471
|
+
});
|
|
472
|
+
return setTimeout(() => {
|
|
473
|
+
void this.flushKey(key);
|
|
474
|
+
}, delayMs);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
private async flushKey(key: string): Promise<void> {
|
|
478
|
+
const entry = this.entries.get(key);
|
|
479
|
+
if (!entry || this.destroyed) return;
|
|
480
|
+
|
|
481
|
+
clearTimeout(entry.timer);
|
|
482
|
+
this.entries.delete(key);
|
|
483
|
+
|
|
484
|
+
const msgCtx = entry.messages.length === 1
|
|
485
|
+
? entry.messages[0]
|
|
486
|
+
: mergeBufferedDirectMessages(entry.messages);
|
|
487
|
+
|
|
488
|
+
try {
|
|
489
|
+
this.logger.debug('Flushing buffered direct messages', {
|
|
490
|
+
chatId: msgCtx.chatId,
|
|
491
|
+
bufferedCount: entry.messages.length,
|
|
492
|
+
});
|
|
493
|
+
await this.onFlush(msgCtx);
|
|
494
|
+
} catch (err) {
|
|
495
|
+
this.logger.error('Failed to flush buffered direct messages', err);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
30
498
|
}
|
|
31
499
|
|
|
32
500
|
/** State held per running gateway instance */
|
|
33
501
|
interface GatewayState {
|
|
502
|
+
accountId: string;
|
|
34
503
|
api: Bitrix24Api;
|
|
504
|
+
bot: BotContext;
|
|
35
505
|
sendService: SendService;
|
|
36
506
|
mediaService: MediaService;
|
|
37
507
|
inboundHandler: InboundHandler;
|
|
508
|
+
eventMode: 'fetch' | 'webhook';
|
|
38
509
|
}
|
|
39
510
|
|
|
40
511
|
let gatewayState: GatewayState | null = null;
|
|
41
512
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
const I18N_MEDIA_DOWNLOAD_FAILED: Record<string, (files: string) => string> = {
|
|
45
|
-
en: (f) => `⚠️ Could not download file(s): ${f}.\n\nFile processing is currently only available for the primary user (webhook owner). This limitation will be removed in a future release.`,
|
|
46
|
-
ru: (f) => `⚠️ Не удалось загрузить файл(ы): ${f}.\n\nОбработка файлов пока доступна только для основного пользователя (автора вебхука). В будущих версиях это ограничение будет снято.`,
|
|
47
|
-
de: (f) => `⚠️ Datei(en) konnten nicht heruntergeladen werden: ${f}.\n\nDateiverarbeitung ist derzeit nur für den Hauptbenutzer (Webhook-Besitzer) verfügbar. Diese Einschränkung wird in einer zukünftigen Version behoben.`,
|
|
48
|
-
es: (f) => `⚠️ No se pudo descargar el/los archivo(s): ${f}.\n\nEl procesamiento de archivos actualmente solo está disponible para el usuario principal (propietario del webhook). Esta limitación se eliminará en una versión futura.`,
|
|
49
|
-
fr: (f) => `⚠️ Impossible de télécharger le(s) fichier(s) : ${f}.\n\nLe traitement des fichiers est actuellement réservé à l'utilisateur principal (propriétaire du webhook). Cette limitation sera levée dans une prochaine version.`,
|
|
50
|
-
pt: (f) => `⚠️ Não foi possível baixar o(s) arquivo(s): ${f}.\n\nO processamento de arquivos está disponível apenas para o usuário principal (dono do webhook). Essa limitação será removida em uma versão futura.`,
|
|
51
|
-
};
|
|
52
|
-
|
|
53
|
-
function mediaDownloadFailedMsg(lang: string | undefined, fileNames: string): string {
|
|
54
|
-
const code = (lang ?? 'en').toLowerCase().slice(0, 2);
|
|
55
|
-
const fn = I18N_MEDIA_DOWNLOAD_FAILED[code] ?? I18N_MEDIA_DOWNLOAD_FAILED.en;
|
|
56
|
-
return fn(fileNames);
|
|
513
|
+
export function __setGatewayStateForTests(state: GatewayState | null): void {
|
|
514
|
+
gatewayState = state;
|
|
57
515
|
}
|
|
58
516
|
|
|
59
517
|
// ─── Default command keyboard ────────────────────────────────────────────────
|
|
@@ -79,8 +537,6 @@ export interface ChannelButton {
|
|
|
79
537
|
|
|
80
538
|
/**
|
|
81
539
|
* Convert OpenClaw button rows to B24 flat KEYBOARD array.
|
|
82
|
-
* Input: Array<Array<{ text, callback_data, style }>>
|
|
83
|
-
* Output: flat array with { TYPE: 'NEWLINE' } separators between rows.
|
|
84
540
|
*/
|
|
85
541
|
export function convertButtonsToKeyboard(rows: ChannelButton[][]): B24Keyboard {
|
|
86
542
|
const keyboard: B24Keyboard = [];
|
|
@@ -90,14 +546,12 @@ export function convertButtonsToKeyboard(rows: ChannelButton[][]): B24Keyboard {
|
|
|
90
546
|
const b24Btn: KeyboardButton = { TEXT: btn.text, DISPLAY: 'LINE' };
|
|
91
547
|
|
|
92
548
|
if (btn.callback_data?.startsWith('/')) {
|
|
93
|
-
// Slash command — use COMMAND + COMMAND_PARAMS
|
|
94
549
|
const parts = btn.callback_data.substring(1).split(' ');
|
|
95
550
|
b24Btn.COMMAND = parts[0];
|
|
96
551
|
if (parts.length > 1) {
|
|
97
552
|
b24Btn.COMMAND_PARAMS = parts.slice(1).join(' ');
|
|
98
553
|
}
|
|
99
554
|
} else if (btn.callback_data) {
|
|
100
|
-
// Non-slash data — insert text into input via PUT action
|
|
101
555
|
b24Btn.ACTION = 'PUT';
|
|
102
556
|
b24Btn.ACTION_VALUE = btn.callback_data;
|
|
103
557
|
}
|
|
@@ -111,7 +565,6 @@ export function convertButtonsToKeyboard(rows: ChannelButton[][]): B24Keyboard {
|
|
|
111
565
|
keyboard.push(b24Btn);
|
|
112
566
|
}
|
|
113
567
|
|
|
114
|
-
// Add NEWLINE separator between rows (not after last row)
|
|
115
568
|
if (i < rows.length - 1) {
|
|
116
569
|
keyboard.push({ TYPE: 'NEWLINE' });
|
|
117
570
|
}
|
|
@@ -122,7 +575,6 @@ export function convertButtonsToKeyboard(rows: ChannelButton[][]): B24Keyboard {
|
|
|
122
575
|
|
|
123
576
|
/**
|
|
124
577
|
* Extract B24 keyboard from a dispatcher payload's channelData.
|
|
125
|
-
* Checks bitrix24-specific data first, then falls back to OpenClaw generic button format.
|
|
126
578
|
*/
|
|
127
579
|
export function extractKeyboardFromPayload(
|
|
128
580
|
payload: { channelData?: Record<string, unknown> },
|
|
@@ -130,13 +582,11 @@ export function extractKeyboardFromPayload(
|
|
|
130
582
|
const cd = payload.channelData;
|
|
131
583
|
if (!cd) return undefined;
|
|
132
584
|
|
|
133
|
-
// Direct B24 keyboard (future-proof: channelData.bitrix24.keyboard)
|
|
134
585
|
const b24Data = cd.bitrix24 as { keyboard?: B24Keyboard } | undefined;
|
|
135
586
|
if (b24Data?.keyboard?.length) {
|
|
136
587
|
return b24Data.keyboard;
|
|
137
588
|
}
|
|
138
589
|
|
|
139
|
-
// Translate from OpenClaw generic button format (channelData.telegram key)
|
|
140
590
|
const tgData = cd.telegram as { buttons?: ChannelButton[][] } | undefined;
|
|
141
591
|
if (tgData?.buttons?.length) {
|
|
142
592
|
return convertButtonsToKeyboard(tgData.buttons);
|
|
@@ -145,105 +595,245 @@ export function extractKeyboardFromPayload(
|
|
|
145
595
|
return undefined;
|
|
146
596
|
}
|
|
147
597
|
|
|
598
|
+
function normalizeCommandReplyPayload(params: {
|
|
599
|
+
commandName: string;
|
|
600
|
+
commandParams: string;
|
|
601
|
+
text: string;
|
|
602
|
+
language?: string;
|
|
603
|
+
}): { text: string; convertMarkdown?: boolean } {
|
|
604
|
+
const { commandName, commandParams, text, language } = params;
|
|
605
|
+
|
|
606
|
+
if (commandName === 'models' && commandParams.trim() === '') {
|
|
607
|
+
const formattedText = formatModelsCommandReply(text, language);
|
|
608
|
+
if (formattedText) {
|
|
609
|
+
return { text: formattedText, convertMarkdown: false };
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
return { text };
|
|
614
|
+
}
|
|
615
|
+
|
|
148
616
|
/**
|
|
149
|
-
*
|
|
150
|
-
|
|
151
|
-
|
|
617
|
+
* Determine effective event mode from config.
|
|
618
|
+
*/
|
|
619
|
+
function resolveEventMode(config: Bitrix24AccountConfig): 'fetch' | 'webhook' {
|
|
620
|
+
return config.eventMode ?? (config.callbackUrl ? 'webhook' : 'fetch');
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/**
|
|
624
|
+
* Generate a stable botToken from webhookUrl if not configured.
|
|
625
|
+
*/
|
|
626
|
+
function resolveBotToken(config: Bitrix24AccountConfig): string | null {
|
|
627
|
+
if (config.botToken) return config.botToken;
|
|
628
|
+
if (!config.webhookUrl) return null;
|
|
629
|
+
// Derive a stable token from webhookUrl (md5, max 32 chars — platform limit)
|
|
630
|
+
return createHash('md5').update(config.webhookUrl).digest('hex');
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
export function buildBotCodeCandidates(
|
|
634
|
+
config: Pick<Bitrix24AccountConfig, 'webhookUrl' | 'botCode'>,
|
|
635
|
+
maxCandidates = AUTO_BOT_CODE_MAX_CANDIDATES,
|
|
636
|
+
): string[] {
|
|
637
|
+
if (config.botCode) {
|
|
638
|
+
return [config.botCode];
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
const webhookUserId = getWebhookUserId(config.webhookUrl);
|
|
642
|
+
const baseCode = webhookUserId ? `openclaw_${webhookUserId}` : 'openclaw';
|
|
643
|
+
const safeMaxCandidates = Math.max(1, maxCandidates);
|
|
644
|
+
|
|
645
|
+
return Array.from({ length: safeMaxCandidates }, (_value, index) => {
|
|
646
|
+
return index === 0 ? baseCode : `${baseCode}_${index + 1}`;
|
|
647
|
+
});
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
function isBotCodeAlreadyTakenError(error: unknown): boolean {
|
|
651
|
+
return error instanceof Bitrix24ApiError && error.code === 'BOT_CODE_ALREADY_TAKEN';
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
interface BotRegistrationState {
|
|
655
|
+
botId: number;
|
|
656
|
+
language?: string;
|
|
657
|
+
isNew: boolean;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
function isFreshBotRegistration(bot: {
|
|
661
|
+
countMessage?: number;
|
|
662
|
+
countCommand?: number;
|
|
663
|
+
countChat?: number;
|
|
664
|
+
countUser?: number;
|
|
665
|
+
}): boolean {
|
|
666
|
+
return (
|
|
667
|
+
(bot.countMessage ?? 0) === 0 &&
|
|
668
|
+
(bot.countCommand ?? 0) === 0 &&
|
|
669
|
+
(bot.countChat ?? 0) === 0 &&
|
|
670
|
+
(bot.countUser ?? 0) === 0
|
|
671
|
+
);
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
async function sendInitialWelcomeToWebhookOwner(params: {
|
|
675
|
+
config: Bitrix24AccountConfig;
|
|
676
|
+
bot: BotContext;
|
|
677
|
+
sendService: SendService;
|
|
678
|
+
language?: string;
|
|
679
|
+
welcomedDialogs: Set<string>;
|
|
680
|
+
logger: Logger;
|
|
681
|
+
}): Promise<void> {
|
|
682
|
+
const { config, bot, sendService, language, welcomedDialogs, logger } = params;
|
|
683
|
+
const ownerId = getWebhookUserId(config.webhookUrl);
|
|
684
|
+
if (!ownerId || !config.webhookUrl || welcomedDialogs.has(ownerId)) {
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
const sendCtx: SendContext = {
|
|
689
|
+
webhookUrl: config.webhookUrl,
|
|
690
|
+
bot,
|
|
691
|
+
dialogId: ownerId,
|
|
692
|
+
};
|
|
693
|
+
const isPairing = config.dmPolicy === 'pairing';
|
|
694
|
+
const text = onboardingMessage(language, config.botName ?? 'OpenClaw', config.dmPolicy);
|
|
695
|
+
const options = isPairing ? undefined : { keyboard: DEFAULT_COMMAND_KEYBOARD };
|
|
696
|
+
|
|
697
|
+
try {
|
|
698
|
+
await sendService.sendText(sendCtx, text, options);
|
|
699
|
+
welcomedDialogs.add(ownerId);
|
|
700
|
+
logger.info('Initial welcome sent to webhook owner', {
|
|
701
|
+
dialogId: ownerId,
|
|
702
|
+
language: language ?? 'en',
|
|
703
|
+
});
|
|
704
|
+
} catch (err) {
|
|
705
|
+
logger.warn('Failed to send initial welcome to webhook owner', err);
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
/**
|
|
710
|
+
* Register or update the bot using imbot.v2.Bot.register / Bot.update.
|
|
152
711
|
*/
|
|
153
712
|
async function ensureBotRegistered(
|
|
154
713
|
api: Bitrix24Api,
|
|
155
714
|
config: Bitrix24AccountConfig,
|
|
715
|
+
botToken: string,
|
|
716
|
+
eventMode: 'fetch' | 'webhook',
|
|
156
717
|
logger: Logger,
|
|
157
|
-
): Promise<
|
|
158
|
-
const { webhookUrl, callbackUrl,
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
718
|
+
): Promise<BotRegistrationState | null> {
|
|
719
|
+
const { webhookUrl, callbackUrl, botName } = config;
|
|
720
|
+
|
|
721
|
+
if (!webhookUrl) return null;
|
|
722
|
+
|
|
723
|
+
if (eventMode === 'webhook' && !callbackUrl) {
|
|
724
|
+
logger.warn('callbackUrl not configured for webhook mode — skipping bot registration');
|
|
163
725
|
return null;
|
|
164
726
|
}
|
|
165
727
|
|
|
166
|
-
const code = botCode ?? 'openclaw';
|
|
167
728
|
const name = botName ?? 'OpenClaw';
|
|
729
|
+
const botCodeCandidates = buildBotCodeCandidates(config);
|
|
168
730
|
|
|
169
|
-
// Check if bot already exists
|
|
731
|
+
// Check if bot already exists via imbot.v2.Bot.list
|
|
170
732
|
try {
|
|
171
|
-
const
|
|
172
|
-
const existing =
|
|
733
|
+
const listResult = await api.listBots(webhookUrl, botToken);
|
|
734
|
+
const existing = botCodeCandidates
|
|
735
|
+
.map((candidate) => listResult.bots.find((botItem) => botItem.code === candidate))
|
|
736
|
+
.find(Boolean);
|
|
173
737
|
|
|
174
738
|
if (existing) {
|
|
175
|
-
logger.info(`Bot "${code}" already registered (ID=${existing.
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
739
|
+
logger.info(`Bot "${existing.code}" already registered (ID=${existing.id}), updating`);
|
|
740
|
+
|
|
741
|
+
const bot: BotContext = { botId: existing.id, botToken };
|
|
742
|
+
const updateFields: Record<string, unknown> = {
|
|
743
|
+
properties: {
|
|
744
|
+
name,
|
|
745
|
+
workPosition: 'AI Assistant',
|
|
746
|
+
avatar: config.botAvatar || DEFAULT_AVATAR_BASE64,
|
|
747
|
+
},
|
|
748
|
+
eventMode,
|
|
180
749
|
};
|
|
181
|
-
updateProps.PERSONAL_PHOTO = config.botAvatar || DEFAULT_AVATAR_BASE64;
|
|
182
750
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
751
|
+
if (eventMode === 'webhook' && callbackUrl) {
|
|
752
|
+
updateFields.webhookUrl = callbackUrl;
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
await api.updateBot(webhookUrl, bot, updateFields);
|
|
756
|
+
return {
|
|
757
|
+
botId: existing.id,
|
|
758
|
+
language: existing.language,
|
|
759
|
+
isNew: false,
|
|
760
|
+
};
|
|
188
761
|
}
|
|
189
762
|
} catch (err) {
|
|
190
763
|
logger.warn('Failed to list existing bots, will try to register', err);
|
|
191
764
|
}
|
|
192
765
|
|
|
193
|
-
// Register new bot
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
766
|
+
// Register new bot via imbot.v2.Bot.register
|
|
767
|
+
for (const code of botCodeCandidates) {
|
|
768
|
+
try {
|
|
769
|
+
const registerFields: {
|
|
770
|
+
code: string;
|
|
771
|
+
properties: { name: string; workPosition: string; avatar?: string };
|
|
772
|
+
type: string;
|
|
773
|
+
eventMode: 'fetch' | 'webhook';
|
|
774
|
+
webhookUrl?: string;
|
|
775
|
+
} = {
|
|
776
|
+
code,
|
|
777
|
+
properties: {
|
|
778
|
+
name,
|
|
779
|
+
workPosition: 'AI Assistant',
|
|
780
|
+
avatar: config.botAvatar || DEFAULT_AVATAR_BASE64,
|
|
781
|
+
},
|
|
782
|
+
type: 'personal',
|
|
783
|
+
eventMode,
|
|
784
|
+
};
|
|
785
|
+
|
|
786
|
+
if (eventMode === 'webhook' && callbackUrl) {
|
|
787
|
+
registerFields.webhookUrl = callbackUrl;
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
const result = await api.registerBot(webhookUrl, botToken, registerFields);
|
|
791
|
+
logger.info(`Bot "${code}" registered in ${eventMode} mode (ID=${result.bot.id})`);
|
|
792
|
+
return {
|
|
793
|
+
botId: result.bot.id,
|
|
794
|
+
language: result.bot.language,
|
|
795
|
+
isNew: isFreshBotRegistration(result.bot),
|
|
796
|
+
};
|
|
797
|
+
} catch (err) {
|
|
798
|
+
if (!config.botCode && isBotCodeAlreadyTakenError(err)) {
|
|
799
|
+
logger.warn(`Bot code "${code}" already taken, trying next candidate`);
|
|
800
|
+
continue;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
logger.error('Failed to register bot', err);
|
|
804
|
+
return null;
|
|
805
|
+
}
|
|
211
806
|
}
|
|
807
|
+
|
|
808
|
+
logger.error('Failed to register bot: exhausted automatic bot code candidates');
|
|
809
|
+
return null;
|
|
212
810
|
}
|
|
213
811
|
|
|
214
812
|
/**
|
|
215
|
-
* Register OpenClaw slash commands with
|
|
216
|
-
*
|
|
813
|
+
* Register OpenClaw slash commands with imbot.v2.Command.register.
|
|
814
|
+
* V2 Command.register is idempotent and doesn't need EVENT_COMMAND_ADD URL.
|
|
217
815
|
*/
|
|
218
816
|
async function ensureCommandsRegistered(
|
|
219
817
|
api: Bitrix24Api,
|
|
220
818
|
config: Bitrix24AccountConfig,
|
|
221
|
-
|
|
819
|
+
bot: BotContext,
|
|
222
820
|
logger: Logger,
|
|
223
821
|
): Promise<void> {
|
|
224
|
-
const { webhookUrl
|
|
225
|
-
if (!webhookUrl
|
|
822
|
+
const { webhookUrl } = config;
|
|
823
|
+
if (!webhookUrl) return;
|
|
226
824
|
|
|
227
825
|
let registered = 0;
|
|
228
826
|
let skipped = 0;
|
|
229
827
|
|
|
230
828
|
for (const cmd of OPENCLAW_COMMANDS) {
|
|
231
829
|
try {
|
|
232
|
-
await api.registerCommand(webhookUrl, {
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
HIDDEN: 'N',
|
|
237
|
-
EXTRANET_SUPPORT: 'N',
|
|
238
|
-
LANG: [
|
|
239
|
-
{ LANGUAGE_ID: 'en', TITLE: cmd.en, ...(cmd.params ? { PARAMS: cmd.params } : {}) },
|
|
240
|
-
{ LANGUAGE_ID: 'ru', TITLE: cmd.ru, ...(cmd.params ? { PARAMS: cmd.params } : {}) },
|
|
241
|
-
],
|
|
242
|
-
EVENT_COMMAND_ADD: callbackUrl,
|
|
830
|
+
await api.registerCommand(webhookUrl, bot, {
|
|
831
|
+
command: cmd.command,
|
|
832
|
+
title: { en: cmd.en, ru: cmd.ru },
|
|
833
|
+
...(cmd.params ? { params: { en: cmd.params, ru: cmd.params } } : {}),
|
|
243
834
|
});
|
|
244
835
|
registered++;
|
|
245
836
|
} catch (err: unknown) {
|
|
246
|
-
// "WRONG_REQUEST" typically means command already exists
|
|
247
837
|
const msg = err instanceof Error ? err.message : String(err);
|
|
248
838
|
if (msg.includes('WRONG_REQUEST') || msg.includes('already')) {
|
|
249
839
|
skipped++;
|
|
@@ -257,11 +847,9 @@ async function ensureCommandsRegistered(
|
|
|
257
847
|
}
|
|
258
848
|
|
|
259
849
|
/**
|
|
260
|
-
* Handle an incoming HTTP request on the webhook route.
|
|
261
|
-
* Called by the HTTP route registered in index.ts.
|
|
850
|
+
* Handle an incoming HTTP request on the webhook route (V2 webhook mode).
|
|
262
851
|
*/
|
|
263
852
|
export async function handleWebhookRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
264
|
-
// Always respond 200 quickly — B24 retries if it doesn't get a fast response
|
|
265
853
|
if (req.method !== 'POST') {
|
|
266
854
|
res.statusCode = 405;
|
|
267
855
|
res.end('Method Not Allowed');
|
|
@@ -274,49 +862,121 @@ export async function handleWebhookRequest(req: IncomingMessage, res: ServerResp
|
|
|
274
862
|
return;
|
|
275
863
|
}
|
|
276
864
|
|
|
865
|
+
if (gatewayState.eventMode === 'fetch') {
|
|
866
|
+
res.statusCode = 200;
|
|
867
|
+
res.end('FETCH mode active');
|
|
868
|
+
return;
|
|
869
|
+
}
|
|
870
|
+
|
|
277
871
|
// Read raw body
|
|
278
872
|
const chunks: Buffer[] = [];
|
|
873
|
+
let bodySize = 0;
|
|
279
874
|
for await (const chunk of req) {
|
|
280
|
-
|
|
875
|
+
const buffer = typeof chunk === 'string' ? Buffer.from(chunk) : chunk;
|
|
876
|
+
chunks.push(buffer);
|
|
877
|
+
bodySize += buffer.length;
|
|
878
|
+
if (bodySize > MAX_WEBHOOK_BODY_BYTES) {
|
|
879
|
+
res.statusCode = 413;
|
|
880
|
+
res.end('Payload Too Large');
|
|
881
|
+
req.destroy();
|
|
882
|
+
return;
|
|
883
|
+
}
|
|
281
884
|
}
|
|
282
885
|
const body = Buffer.concat(chunks).toString('utf-8');
|
|
283
886
|
|
|
284
|
-
// Respond immediately
|
|
285
|
-
res.statusCode = 200;
|
|
286
|
-
res.setHeader('Content-Type', 'text/plain');
|
|
287
|
-
res.end('ok');
|
|
288
|
-
|
|
289
|
-
// Process in background
|
|
290
887
|
try {
|
|
291
|
-
await gatewayState.inboundHandler.handleWebhook(body);
|
|
888
|
+
const handled = await gatewayState.inboundHandler.handleWebhook(body);
|
|
889
|
+
if (!handled) {
|
|
890
|
+
res.statusCode = 400;
|
|
891
|
+
res.end('Invalid webhook payload');
|
|
892
|
+
return;
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
res.statusCode = 200;
|
|
896
|
+
res.setHeader('Content-Type', 'application/json');
|
|
897
|
+
res.end(JSON.stringify({ status: 'ok' }));
|
|
292
898
|
} catch (err) {
|
|
293
|
-
defaultLogger.error('Error handling Bitrix24 webhook', err);
|
|
899
|
+
defaultLogger.error('Error handling Bitrix24 V2 webhook', err);
|
|
900
|
+
res.statusCode = 500;
|
|
901
|
+
res.end('Webhook processing failed');
|
|
294
902
|
}
|
|
295
903
|
}
|
|
296
904
|
|
|
297
905
|
// ─── Outbound adapter helpers ────────────────────────────────────────────────
|
|
298
906
|
|
|
299
|
-
/**
|
|
300
|
-
* Build a minimal SendContext from the delivery pipeline's outbound context.
|
|
301
|
-
* The pipeline provides `cfg` (full OpenClaw config) and `to` (normalized dialog ID).
|
|
302
|
-
* We resolve the account config to get `webhookUrl`.
|
|
303
|
-
*/
|
|
304
907
|
function resolveOutboundSendCtx(params: {
|
|
305
908
|
cfg: Record<string, unknown>;
|
|
306
909
|
to: string;
|
|
307
910
|
accountId?: string;
|
|
308
|
-
}):
|
|
911
|
+
}): SendContext | null {
|
|
309
912
|
const { config } = resolveAccount(params.cfg, params.accountId);
|
|
913
|
+
if (!config.webhookUrl || !gatewayState) return null;
|
|
310
914
|
return {
|
|
311
915
|
webhookUrl: config.webhookUrl,
|
|
916
|
+
bot: gatewayState.bot,
|
|
312
917
|
dialogId: params.to,
|
|
313
918
|
};
|
|
314
919
|
}
|
|
315
920
|
|
|
921
|
+
function collectOutboundMediaUrls(input: {
|
|
922
|
+
mediaUrl?: string;
|
|
923
|
+
payload?: { mediaUrl?: string; mediaUrls?: string[] };
|
|
924
|
+
}): string[] {
|
|
925
|
+
const mediaUrls: string[] = [];
|
|
926
|
+
|
|
927
|
+
if (input.mediaUrl) {
|
|
928
|
+
mediaUrls.push(input.mediaUrl);
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
if (input.payload?.mediaUrl) {
|
|
932
|
+
mediaUrls.push(input.payload.mediaUrl);
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
if (Array.isArray(input.payload?.mediaUrls)) {
|
|
936
|
+
mediaUrls.push(...input.payload.mediaUrls.filter((item) => typeof item === 'string'));
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
return [...new Set(mediaUrls)];
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
async function uploadOutboundMedia(params: {
|
|
943
|
+
sendCtx: SendContext;
|
|
944
|
+
mediaUrls: string[];
|
|
945
|
+
text?: string;
|
|
946
|
+
}): Promise<string> {
|
|
947
|
+
if (!gatewayState) {
|
|
948
|
+
throw new Error('Bitrix24 gateway not started');
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
let lastMessageId = '';
|
|
952
|
+
let message = params.text;
|
|
953
|
+
|
|
954
|
+
for (const mediaUrl of params.mediaUrls) {
|
|
955
|
+
const result = await gatewayState.mediaService.uploadMediaToChat({
|
|
956
|
+
localPath: mediaUrl,
|
|
957
|
+
fileName: basename(mediaUrl),
|
|
958
|
+
webhookUrl: params.sendCtx.webhookUrl,
|
|
959
|
+
bot: params.sendCtx.bot,
|
|
960
|
+
dialogId: params.sendCtx.dialogId,
|
|
961
|
+
message: message || undefined,
|
|
962
|
+
});
|
|
963
|
+
|
|
964
|
+
if (!result.ok) {
|
|
965
|
+
throw new Error(`Failed to upload media: ${basename(mediaUrl)}`);
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
if (result.messageId) {
|
|
969
|
+
lastMessageId = String(result.messageId);
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
message = undefined;
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
return lastMessageId;
|
|
976
|
+
}
|
|
977
|
+
|
|
316
978
|
/**
|
|
317
979
|
* The Bitrix24 channel plugin object.
|
|
318
|
-
*
|
|
319
|
-
* Implements the OpenClaw ChannelPlugin interface.
|
|
320
980
|
*/
|
|
321
981
|
export const bitrix24Plugin = {
|
|
322
982
|
id: 'bitrix24',
|
|
@@ -327,34 +987,25 @@ export const bitrix24Plugin = {
|
|
|
327
987
|
selectionLabel: 'Bitrix24 (Messenger)',
|
|
328
988
|
docsPath: '/channels/bitrix24',
|
|
329
989
|
docsLabel: 'bitrix24',
|
|
330
|
-
blurb: 'Connect to Bitrix24 Messenger via chat bot REST API.',
|
|
990
|
+
blurb: 'Connect to Bitrix24 Messenger via chat bot REST API (V2).',
|
|
331
991
|
aliases: ['b24', 'bx24'],
|
|
332
992
|
},
|
|
333
993
|
|
|
334
994
|
capabilities: {
|
|
335
|
-
chatTypes: ['direct'
|
|
995
|
+
chatTypes: ['direct'] as const,
|
|
336
996
|
media: true,
|
|
337
|
-
reactions:
|
|
997
|
+
reactions: true,
|
|
338
998
|
threads: false,
|
|
339
999
|
nativeCommands: true,
|
|
340
1000
|
inlineButtons: 'all',
|
|
341
1001
|
},
|
|
342
1002
|
|
|
343
1003
|
messaging: {
|
|
344
|
-
|
|
345
|
-
* Normalize target ID by stripping the channel prefix.
|
|
346
|
-
* Called by the delivery pipeline so that `to` in outbound context is a clean numeric ID.
|
|
347
|
-
*/
|
|
348
|
-
normalizeTarget: (raw: string) => raw.trim().replace(/^(bitrix24|b24|bx24):/i, ''),
|
|
1004
|
+
normalizeTarget: (raw: string) => raw.trim().replace(CHANNEL_PREFIX_RE, ''),
|
|
349
1005
|
targetResolver: {
|
|
350
1006
|
hint: 'Use a numeric chat/dialog ID, e.g. "1" or "chat42".',
|
|
351
|
-
/**
|
|
352
|
-
* Recognize any numeric string as a valid Bitrix24 target ID.
|
|
353
|
-
* B24 dialog IDs can be short (e.g. "1"), so the default 6+ digit check is too strict.
|
|
354
|
-
*/
|
|
355
1007
|
looksLikeId: (raw: string, _normalized: string) => {
|
|
356
|
-
|
|
357
|
-
const stripped = raw.trim().replace(/^(bitrix24|b24|bx24):/i, '');
|
|
1008
|
+
const stripped = raw.trim().replace(CHANNEL_PREFIX_RE, '');
|
|
358
1009
|
return /^\d+$/.test(stripped);
|
|
359
1010
|
},
|
|
360
1011
|
},
|
|
@@ -367,16 +1018,20 @@ export const bitrix24Plugin = {
|
|
|
367
1018
|
},
|
|
368
1019
|
|
|
369
1020
|
security: {
|
|
370
|
-
resolveDmPolicy: (params: { cfg?: Record<string, unknown>; accountId?: string; account?: { config?: Record<string, unknown> } }) =>
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
1021
|
+
resolveDmPolicy: (params: { cfg?: Record<string, unknown>; accountId?: string; account?: { config?: Record<string, unknown> } }) => {
|
|
1022
|
+
const securityConfig = resolveSecurityConfig(params);
|
|
1023
|
+
|
|
1024
|
+
return {
|
|
1025
|
+
policy: securityConfig.dmPolicy === 'pairing' ? 'pairing' : 'webhookUser',
|
|
1026
|
+
allowFrom: normalizeAllowList(securityConfig.allowFrom),
|
|
1027
|
+
policyPath: 'channels.bitrix24.dmPolicy',
|
|
1028
|
+
allowFromPath: 'channels.bitrix24.allowFrom',
|
|
1029
|
+
approveHint: 'openclaw pairing approve bitrix24 <CODE>',
|
|
1030
|
+
normalizeEntry: (raw: string) => raw.replace(CHANNEL_PREFIX_RE, ''),
|
|
1031
|
+
};
|
|
1032
|
+
},
|
|
378
1033
|
normalizeAllowFrom: (entry: string) =>
|
|
379
|
-
entry.replace(
|
|
1034
|
+
entry.replace(CHANNEL_PREFIX_RE, ''),
|
|
380
1035
|
},
|
|
381
1036
|
|
|
382
1037
|
pairing: {
|
|
@@ -384,12 +1039,16 @@ export const bitrix24Plugin = {
|
|
|
384
1039
|
normalizeAllowEntry: (entry: string) => normalizeAllowEntry(entry),
|
|
385
1040
|
notifyApproval: async (params: { cfg: Record<string, unknown>; id: string; runtime?: unknown }) => {
|
|
386
1041
|
const { config: acctCfg } = resolveAccount(params.cfg);
|
|
387
|
-
if (!acctCfg.webhookUrl) return;
|
|
388
|
-
const
|
|
1042
|
+
if (!acctCfg.webhookUrl || !gatewayState) return;
|
|
1043
|
+
const sendCtx: SendContext = {
|
|
1044
|
+
webhookUrl: acctCfg.webhookUrl,
|
|
1045
|
+
bot: gatewayState.bot,
|
|
1046
|
+
dialogId: params.id,
|
|
1047
|
+
};
|
|
389
1048
|
try {
|
|
390
|
-
await
|
|
391
|
-
}
|
|
392
|
-
|
|
1049
|
+
await gatewayState.sendService.sendText(sendCtx, '\u2705 OpenClaw access approved.');
|
|
1050
|
+
} catch (err) {
|
|
1051
|
+
defaultLogger.warn('Failed to notify approved Bitrix24 user', err);
|
|
393
1052
|
}
|
|
394
1053
|
},
|
|
395
1054
|
} satisfies ChannelPairingAdapter,
|
|
@@ -398,12 +1057,6 @@ export const bitrix24Plugin = {
|
|
|
398
1057
|
deliveryMode: 'direct' as const,
|
|
399
1058
|
textChunkLimit: 4000,
|
|
400
1059
|
|
|
401
|
-
/**
|
|
402
|
-
* Send a text message to B24 via the bot.
|
|
403
|
-
* Called by the OpenClaw delivery pipeline (message tool path).
|
|
404
|
-
*
|
|
405
|
-
* Context shape: { cfg, to, accountId, text, replyToId?, threadId?, ... }
|
|
406
|
-
*/
|
|
407
1060
|
sendText: async (ctx: {
|
|
408
1061
|
cfg: Record<string, unknown>;
|
|
409
1062
|
to: string;
|
|
@@ -411,20 +1064,12 @@ export const bitrix24Plugin = {
|
|
|
411
1064
|
text: string;
|
|
412
1065
|
[key: string]: unknown;
|
|
413
1066
|
}) => {
|
|
414
|
-
if (!gatewayState) throw new Error('Bitrix24 gateway not started');
|
|
415
1067
|
const sendCtx = resolveOutboundSendCtx(ctx);
|
|
1068
|
+
if (!sendCtx || !gatewayState) throw new Error('Bitrix24 gateway not started');
|
|
416
1069
|
const result = await gatewayState.sendService.sendText(sendCtx, ctx.text);
|
|
417
1070
|
return { messageId: String(result.messageId ?? '') };
|
|
418
1071
|
},
|
|
419
1072
|
|
|
420
|
-
/**
|
|
421
|
-
* Send a media message to B24.
|
|
422
|
-
* Called by the delivery pipeline when the agent sends media.
|
|
423
|
-
*
|
|
424
|
-
* Note: full media upload requires OAuth bot token (clientEndpoint + botToken)
|
|
425
|
-
* which is only available in the reply path (inbound webhook events).
|
|
426
|
-
* In the message tool path we only have the webhook URL, so we send the caption text.
|
|
427
|
-
*/
|
|
428
1073
|
sendMedia: async (ctx: {
|
|
429
1074
|
cfg: Record<string, unknown>;
|
|
430
1075
|
to: string;
|
|
@@ -433,10 +1078,19 @@ export const bitrix24Plugin = {
|
|
|
433
1078
|
mediaUrl?: string;
|
|
434
1079
|
[key: string]: unknown;
|
|
435
1080
|
}) => {
|
|
436
|
-
if (!gatewayState) throw new Error('Bitrix24 gateway not started');
|
|
437
1081
|
const sendCtx = resolveOutboundSendCtx(ctx);
|
|
438
|
-
|
|
439
|
-
|
|
1082
|
+
if (!sendCtx || !gatewayState) throw new Error('Bitrix24 gateway not started');
|
|
1083
|
+
|
|
1084
|
+
const mediaUrls = collectOutboundMediaUrls({ mediaUrl: ctx.mediaUrl });
|
|
1085
|
+
if (mediaUrls.length > 0) {
|
|
1086
|
+
const messageId = await uploadOutboundMedia({
|
|
1087
|
+
sendCtx,
|
|
1088
|
+
mediaUrls,
|
|
1089
|
+
text: ctx.text,
|
|
1090
|
+
});
|
|
1091
|
+
return { messageId };
|
|
1092
|
+
}
|
|
1093
|
+
|
|
440
1094
|
if (ctx.text) {
|
|
441
1095
|
const result = await gatewayState.sendService.sendText(sendCtx, ctx.text);
|
|
442
1096
|
return { messageId: String(result.messageId ?? '') };
|
|
@@ -444,12 +1098,6 @@ export const bitrix24Plugin = {
|
|
|
444
1098
|
return { messageId: '' };
|
|
445
1099
|
},
|
|
446
1100
|
|
|
447
|
-
/**
|
|
448
|
-
* Send a rich payload with optional channelData (keyboards, etc.) to B24.
|
|
449
|
-
* Called by the delivery pipeline when payload includes channelData.
|
|
450
|
-
*
|
|
451
|
-
* Context shape: { cfg, to, accountId, text, mediaUrl?, payload, ... }
|
|
452
|
-
*/
|
|
453
1101
|
sendPayload: async (ctx: {
|
|
454
1102
|
cfg: Record<string, unknown>;
|
|
455
1103
|
to: string;
|
|
@@ -459,15 +1107,35 @@ export const bitrix24Plugin = {
|
|
|
459
1107
|
payload?: { channelData?: Record<string, unknown>; [key: string]: unknown };
|
|
460
1108
|
[key: string]: unknown;
|
|
461
1109
|
}) => {
|
|
462
|
-
if (!gatewayState) throw new Error('Bitrix24 gateway not started');
|
|
463
1110
|
const sendCtx = resolveOutboundSendCtx(ctx);
|
|
1111
|
+
if (!sendCtx || !gatewayState) throw new Error('Bitrix24 gateway not started');
|
|
464
1112
|
|
|
465
|
-
// Extract keyboard from channelData
|
|
466
1113
|
const keyboard = ctx.payload?.channelData
|
|
467
1114
|
? extractKeyboardFromPayload({ channelData: ctx.payload.channelData })
|
|
468
1115
|
: undefined;
|
|
1116
|
+
const mediaUrls = collectOutboundMediaUrls({
|
|
1117
|
+
mediaUrl: ctx.mediaUrl,
|
|
1118
|
+
payload: ctx.payload as { mediaUrl?: string; mediaUrls?: string[] } | undefined,
|
|
1119
|
+
});
|
|
1120
|
+
|
|
1121
|
+
if (mediaUrls.length > 0) {
|
|
1122
|
+
const uploadedMessageId = await uploadOutboundMedia({
|
|
1123
|
+
sendCtx,
|
|
1124
|
+
mediaUrls,
|
|
1125
|
+
});
|
|
1126
|
+
|
|
1127
|
+
if (ctx.text) {
|
|
1128
|
+
const result = await gatewayState.sendService.sendText(
|
|
1129
|
+
sendCtx,
|
|
1130
|
+
ctx.text,
|
|
1131
|
+
keyboard ? { keyboard } : undefined,
|
|
1132
|
+
);
|
|
1133
|
+
return { messageId: String(result.messageId ?? uploadedMessageId) };
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
return { messageId: uploadedMessageId };
|
|
1137
|
+
}
|
|
469
1138
|
|
|
470
|
-
// Send text + keyboard
|
|
471
1139
|
if (ctx.text) {
|
|
472
1140
|
const result = await gatewayState.sendService.sendText(
|
|
473
1141
|
sendCtx,
|
|
@@ -480,10 +1148,100 @@ export const bitrix24Plugin = {
|
|
|
480
1148
|
},
|
|
481
1149
|
},
|
|
482
1150
|
|
|
1151
|
+
// ─── Actions (agent-driven: reactions, etc.) ────────────────────────────
|
|
1152
|
+
|
|
1153
|
+
actions: {
|
|
1154
|
+
listActions: (_params: { cfg: Record<string, unknown> }): string[] => {
|
|
1155
|
+
return ['react'];
|
|
1156
|
+
},
|
|
1157
|
+
|
|
1158
|
+
supportsAction: (params: { action: string }): boolean => {
|
|
1159
|
+
return params.action === 'react';
|
|
1160
|
+
},
|
|
1161
|
+
|
|
1162
|
+
handleAction: async (ctx: {
|
|
1163
|
+
action: string;
|
|
1164
|
+
channel: string;
|
|
1165
|
+
cfg: Record<string, unknown>;
|
|
1166
|
+
accountId?: string;
|
|
1167
|
+
params: Record<string, unknown>;
|
|
1168
|
+
[key: string]: unknown;
|
|
1169
|
+
}): Promise<Record<string, unknown> | null> => {
|
|
1170
|
+
if (ctx.action !== 'react') return null;
|
|
1171
|
+
|
|
1172
|
+
// Helper: wrap payload as gateway-compatible tool result
|
|
1173
|
+
const toolResult = (payload: Record<string, unknown>) => ({
|
|
1174
|
+
content: [{ type: 'text' as const, text: JSON.stringify(payload, null, 2) }],
|
|
1175
|
+
details: payload,
|
|
1176
|
+
});
|
|
1177
|
+
|
|
1178
|
+
const { config } = resolveAccount(ctx.cfg, ctx.accountId);
|
|
1179
|
+
if (!config.webhookUrl || !gatewayState) {
|
|
1180
|
+
return toolResult({ ok: false, reason: 'not_started', hint: 'Bitrix24 gateway not started. Do not retry.' });
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
const bot = gatewayState.bot;
|
|
1184
|
+
const api = gatewayState.api;
|
|
1185
|
+
const params = ctx.params;
|
|
1186
|
+
|
|
1187
|
+
// Resolve messageId: explicit param → toolContext.currentMessageId fallback
|
|
1188
|
+
const toolContext = (ctx as Record<string, unknown>).toolContext as
|
|
1189
|
+
| { currentMessageId?: string | number } | undefined;
|
|
1190
|
+
const rawMessageId = params.messageId ?? params.message_id ?? toolContext?.currentMessageId;
|
|
1191
|
+
const messageId = Number(rawMessageId);
|
|
1192
|
+
|
|
1193
|
+
if (!Number.isFinite(messageId) || messageId <= 0) {
|
|
1194
|
+
return toolResult({ ok: false, reason: 'missing_message_id', hint: 'Valid messageId is required for Bitrix24 reactions. Do not retry.' });
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
const emoji = String(params.emoji ?? '').trim();
|
|
1198
|
+
const remove = params.remove === true || params.remove === 'true';
|
|
1199
|
+
|
|
1200
|
+
if (remove) {
|
|
1201
|
+
// Remove reaction — need to know which one
|
|
1202
|
+
const reactionCode = emoji ? resolveB24Reaction(emoji) : null;
|
|
1203
|
+
if (!reactionCode) {
|
|
1204
|
+
return toolResult({ ok: false, reason: 'missing_emoji', hint: 'Emoji is required to remove a Bitrix24 reaction.' });
|
|
1205
|
+
}
|
|
1206
|
+
try {
|
|
1207
|
+
await api.deleteReaction(config.webhookUrl, bot, messageId, reactionCode);
|
|
1208
|
+
return toolResult({ ok: true, removed: true });
|
|
1209
|
+
} catch (err) {
|
|
1210
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
1211
|
+
return toolResult({ ok: false, reason: 'error', hint: `Failed to remove reaction: ${errMsg}. Do not retry.` });
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
// Add reaction
|
|
1216
|
+
if (!emoji) {
|
|
1217
|
+
return toolResult({ ok: false, reason: 'missing_emoji', hint: 'Emoji is required for Bitrix24 reactions.' });
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
const reactionCode = resolveB24Reaction(emoji);
|
|
1221
|
+
if (!reactionCode) {
|
|
1222
|
+
return toolResult({
|
|
1223
|
+
ok: false,
|
|
1224
|
+
reason: 'REACTION_NOT_FOUND',
|
|
1225
|
+
emoji,
|
|
1226
|
+
hint: `Emoji "${emoji}" is not supported for Bitrix24 reactions. Add it to your reaction disallow list so you do not try it again.`,
|
|
1227
|
+
});
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
try {
|
|
1231
|
+
await api.addReaction(config.webhookUrl, bot, messageId, reactionCode);
|
|
1232
|
+
return toolResult({ ok: true, added: emoji });
|
|
1233
|
+
} catch (err) {
|
|
1234
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
1235
|
+
const isAlreadySet = errMsg.includes('REACTION_ALREADY_SET');
|
|
1236
|
+
if (isAlreadySet) {
|
|
1237
|
+
return toolResult({ ok: true, added: emoji, warning: 'Reaction already set.' });
|
|
1238
|
+
}
|
|
1239
|
+
return toolResult({ ok: false, reason: 'error', emoji, hint: `Reaction failed: ${errMsg}. Do not retry.` });
|
|
1240
|
+
}
|
|
1241
|
+
},
|
|
1242
|
+
},
|
|
1243
|
+
|
|
483
1244
|
gateway: {
|
|
484
|
-
/**
|
|
485
|
-
* Start a channel account. Called by OpenClaw for each configured account.
|
|
486
|
-
*/
|
|
487
1245
|
startAccount: async (ctx: {
|
|
488
1246
|
cfg: Record<string, unknown>;
|
|
489
1247
|
accountId: string;
|
|
@@ -494,117 +1252,142 @@ export const bitrix24Plugin = {
|
|
|
494
1252
|
setStatus?: (status: Record<string, unknown>) => void;
|
|
495
1253
|
}) => {
|
|
496
1254
|
const logger = ctx.log ?? defaultLogger;
|
|
497
|
-
|
|
1255
|
+
|
|
1256
|
+
// Guard: only one account can run at a time (singleton gateway)
|
|
1257
|
+
if (gatewayState !== null) {
|
|
1258
|
+
throw new Error(
|
|
1259
|
+
`Bitrix24 channel already started for account "${gatewayState.accountId}". ` +
|
|
1260
|
+
`Cannot start account "${ctx.accountId}" concurrently.`,
|
|
1261
|
+
);
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
const config = getConfig(ctx.cfg, ctx.accountId);
|
|
498
1265
|
|
|
499
1266
|
if (!config.webhookUrl) {
|
|
500
1267
|
logger.warn(`[${ctx.accountId}] no webhookUrl configured, skipping`);
|
|
501
1268
|
return;
|
|
502
1269
|
}
|
|
1270
|
+
// Safe to use without ! after this point
|
|
1271
|
+
const webhookUrl: string = config.webhookUrl;
|
|
503
1272
|
|
|
504
1273
|
logger.info(`[${ctx.accountId}] starting Bitrix24 channel`);
|
|
505
1274
|
|
|
506
|
-
|
|
507
|
-
const
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
const
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
1275
|
+
const api = new Bitrix24Api({ logger });
|
|
1276
|
+
const botToken = resolveBotToken(config);
|
|
1277
|
+
if (!botToken) {
|
|
1278
|
+
logger.error(`[${ctx.accountId}] cannot derive botToken — webhookUrl is missing`);
|
|
1279
|
+
api.destroy();
|
|
1280
|
+
return;
|
|
1281
|
+
}
|
|
1282
|
+
const welcomedDialogs = new Set<string>();
|
|
1283
|
+
const deniedDialogs = new Map<string, number>();
|
|
1284
|
+
|
|
1285
|
+
// Cleanup stale denied dialog entries once per day
|
|
1286
|
+
const DENIED_CLEANUP_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
|
1287
|
+
const deniedCleanupTimer = setInterval(() => {
|
|
1288
|
+
deniedDialogs.clear();
|
|
1289
|
+
}, DENIED_CLEANUP_INTERVAL_MS);
|
|
1290
|
+
if (deniedCleanupTimer && typeof deniedCleanupTimer === 'object' && 'unref' in deniedCleanupTimer) {
|
|
1291
|
+
deniedCleanupTimer.unref();
|
|
520
1292
|
}
|
|
521
1293
|
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
1294
|
+
// Determine event mode
|
|
1295
|
+
const eventMode = resolveEventMode(config);
|
|
1296
|
+
logger.info(`[${ctx.accountId}] event mode: ${eventMode}`);
|
|
525
1297
|
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
senderId: msgCtx.senderId,
|
|
529
|
-
chatId: msgCtx.chatId,
|
|
530
|
-
messageId: msgCtx.messageId,
|
|
531
|
-
textLen: msgCtx.text.length,
|
|
532
|
-
});
|
|
1298
|
+
// Register or update bot on the B24 portal (V2 API)
|
|
1299
|
+
const botRegistration = await ensureBotRegistered(api, config, botToken, eventMode, logger);
|
|
533
1300
|
|
|
534
|
-
|
|
535
|
-
|
|
1301
|
+
if (!botRegistration) {
|
|
1302
|
+
logger.error(`[${ctx.accountId}] bot registration failed, cannot start`);
|
|
1303
|
+
clearInterval(deniedCleanupTimer);
|
|
1304
|
+
api.destroy();
|
|
1305
|
+
return;
|
|
1306
|
+
}
|
|
536
1307
|
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
1308
|
+
const bot: BotContext = { botId: botRegistration.botId, botToken };
|
|
1309
|
+
|
|
1310
|
+
// Sync user event subscription with agent mode setting
|
|
1311
|
+
if (eventMode === 'fetch') {
|
|
1312
|
+
try {
|
|
1313
|
+
if (config.agentMode) {
|
|
1314
|
+
await api.subscribeUserEvents(webhookUrl);
|
|
1315
|
+
logger.info('User events subscription active (agent mode)');
|
|
1316
|
+
} else {
|
|
1317
|
+
await api.unsubscribeUserEvents(webhookUrl);
|
|
1318
|
+
logger.debug('User events unsubscribed (agent mode off)');
|
|
1319
|
+
}
|
|
1320
|
+
} catch (err) {
|
|
1321
|
+
logger.warn('Failed to sync user events subscription', err);
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
const sendService = new SendService(api, logger);
|
|
1326
|
+
const mediaService = new MediaService(api, logger);
|
|
1327
|
+
const processAllowedMessage = async (msgCtx: B24MsgContext): Promise<void> => {
|
|
1328
|
+
const runtime = getBitrix24Runtime();
|
|
1329
|
+
const cfg = runtime.config.loadConfig();
|
|
1330
|
+
const sendCtx: SendContext = {
|
|
1331
|
+
webhookUrl,
|
|
1332
|
+
bot,
|
|
1333
|
+
dialogId: msgCtx.chatId,
|
|
1334
|
+
};
|
|
1335
|
+
let downloadedMedia: DownloadedMedia[] = [];
|
|
1336
|
+
|
|
1337
|
+
try {
|
|
1338
|
+
const replyStatusHeartbeat = createReplyStatusHeartbeat({
|
|
1339
|
+
sendService,
|
|
1340
|
+
sendCtx,
|
|
540
1341
|
config,
|
|
541
|
-
runtime,
|
|
542
|
-
accountId: ctx.accountId,
|
|
543
|
-
pairingAdapter: bitrix24Plugin.pairing,
|
|
544
|
-
sendReply: async (text: string) => {
|
|
545
|
-
const replySendCtx = {
|
|
546
|
-
webhookUrl: config.webhookUrl,
|
|
547
|
-
clientEndpoint: msgCtx.clientEndpoint,
|
|
548
|
-
botToken: msgCtx.botToken,
|
|
549
|
-
dialogId: msgCtx.chatId,
|
|
550
|
-
};
|
|
551
|
-
await sendService.sendText(replySendCtx, text, { convertMarkdown: false });
|
|
552
|
-
},
|
|
553
|
-
logger,
|
|
554
1342
|
});
|
|
555
1343
|
|
|
556
|
-
if (accessResult !== 'allow') {
|
|
557
|
-
logger.debug(`Message blocked (${accessResult})`, { senderId: msgCtx.senderId });
|
|
558
|
-
return;
|
|
559
|
-
}
|
|
560
|
-
|
|
561
1344
|
// Download media files if present
|
|
562
1345
|
let mediaFields: Record<string, unknown> = {};
|
|
563
1346
|
if (msgCtx.media.length > 0) {
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
1347
|
+
await notifyStatus(sendService, sendCtx, config, 'IMBOT_AGENT_ACTION_PROCESSING');
|
|
1348
|
+
replyStatusHeartbeat.holdFor(PHASE_STATUS_DURATION_SECONDS);
|
|
1349
|
+
|
|
1350
|
+
downloadedMedia = (await mapWithConcurrency(
|
|
1351
|
+
msgCtx.media,
|
|
1352
|
+
MEDIA_DOWNLOAD_CONCURRENCY,
|
|
1353
|
+
(mediaItem) => mediaService.downloadMedia({
|
|
1354
|
+
fileId: mediaItem.id,
|
|
1355
|
+
fileName: mediaItem.name,
|
|
1356
|
+
extension: mediaItem.extension,
|
|
1357
|
+
webhookUrl,
|
|
1358
|
+
bot,
|
|
1359
|
+
dialogId: msgCtx.chatId,
|
|
1360
|
+
}),
|
|
575
1361
|
)).filter(Boolean) as DownloadedMedia[];
|
|
576
1362
|
|
|
577
|
-
if (
|
|
1363
|
+
if (downloadedMedia.length > 0) {
|
|
578
1364
|
mediaFields = {
|
|
579
|
-
MediaPath:
|
|
580
|
-
MediaType:
|
|
581
|
-
MediaUrl:
|
|
582
|
-
MediaPaths:
|
|
583
|
-
MediaUrls:
|
|
584
|
-
MediaTypes:
|
|
1365
|
+
MediaPath: downloadedMedia[0].path,
|
|
1366
|
+
MediaType: downloadedMedia[0].contentType,
|
|
1367
|
+
MediaUrl: downloadedMedia[0].path,
|
|
1368
|
+
MediaPaths: downloadedMedia.map((mediaItem) => mediaItem.path),
|
|
1369
|
+
MediaUrls: downloadedMedia.map((mediaItem) => mediaItem.path),
|
|
1370
|
+
MediaTypes: downloadedMedia.map((mediaItem) => mediaItem.contentType),
|
|
585
1371
|
};
|
|
586
1372
|
} else {
|
|
587
|
-
|
|
588
|
-
const fileNames = msgCtx.media.map((m) => m.name).join(', ');
|
|
1373
|
+
const fileNames = msgCtx.media.map((mediaItem) => mediaItem.name).join(', ');
|
|
589
1374
|
logger.warn('All media downloads failed, notifying user', { fileNames });
|
|
590
|
-
const errSendCtx = {
|
|
591
|
-
webhookUrl: config.webhookUrl,
|
|
592
|
-
clientEndpoint: msgCtx.clientEndpoint,
|
|
593
|
-
botToken: msgCtx.botToken,
|
|
594
|
-
dialogId: msgCtx.chatId,
|
|
595
|
-
};
|
|
596
1375
|
await sendService.sendText(
|
|
597
|
-
|
|
598
|
-
|
|
1376
|
+
sendCtx,
|
|
1377
|
+
mediaDownloadFailed(msgCtx.language, fileNames),
|
|
599
1378
|
);
|
|
600
1379
|
return;
|
|
601
1380
|
}
|
|
1381
|
+
} else {
|
|
1382
|
+
await notifyStatus(sendService, sendCtx, config, 'IMBOT_AGENT_ACTION_ANALYZING');
|
|
1383
|
+
replyStatusHeartbeat.holdFor(PHASE_STATUS_DURATION_SECONDS);
|
|
602
1384
|
}
|
|
603
1385
|
|
|
604
1386
|
// Use placeholder body for media-only messages
|
|
605
1387
|
let body = msgCtx.text;
|
|
606
1388
|
if (!body && msgCtx.media.length > 0) {
|
|
607
|
-
const hasImage =
|
|
1389
|
+
const hasImage = downloadedMedia.some((mediaItem) => mediaItem.contentType.startsWith('image/'))
|
|
1390
|
+
|| msgCtx.media.some((mediaItem) => mediaItem.type === 'image');
|
|
608
1391
|
body = hasImage ? '<media:image>' : '<media:document>';
|
|
609
1392
|
}
|
|
610
1393
|
|
|
@@ -625,7 +1408,6 @@ export const bitrix24Plugin = {
|
|
|
625
1408
|
matchedBy: route.matchedBy,
|
|
626
1409
|
});
|
|
627
1410
|
|
|
628
|
-
// Build and finalize inbound context for OpenClaw agent
|
|
629
1411
|
const inboundCtx = runtime.channel.reply.finalizeInboundContext({
|
|
630
1412
|
Body: body,
|
|
631
1413
|
BodyForAgent: body,
|
|
@@ -649,41 +1431,34 @@ export const bitrix24Plugin = {
|
|
|
649
1431
|
...mediaFields,
|
|
650
1432
|
});
|
|
651
1433
|
|
|
652
|
-
const sendCtx = {
|
|
653
|
-
webhookUrl: config.webhookUrl,
|
|
654
|
-
clientEndpoint: msgCtx.clientEndpoint,
|
|
655
|
-
botToken: msgCtx.botToken,
|
|
656
|
-
dialogId: msgCtx.chatId,
|
|
657
|
-
};
|
|
658
|
-
|
|
659
|
-
// Dispatch to AI agent; deliver callback sends reply back to B24
|
|
660
1434
|
try {
|
|
661
1435
|
await runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
662
1436
|
ctx: inboundCtx,
|
|
663
1437
|
cfg,
|
|
664
1438
|
dispatcherOptions: {
|
|
665
1439
|
deliver: async (payload) => {
|
|
666
|
-
|
|
1440
|
+
await replyStatusHeartbeat.stopAndWait();
|
|
667
1441
|
const mediaUrls = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
|
|
668
1442
|
for (const mediaUrl of mediaUrls) {
|
|
669
|
-
await mediaService.uploadMediaToChat({
|
|
1443
|
+
const uploadResult = await mediaService.uploadMediaToChat({
|
|
670
1444
|
localPath: mediaUrl,
|
|
671
1445
|
fileName: basename(mediaUrl),
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
1446
|
+
webhookUrl,
|
|
1447
|
+
bot,
|
|
1448
|
+
dialogId: msgCtx.chatId,
|
|
675
1449
|
});
|
|
1450
|
+
|
|
1451
|
+
if (!uploadResult.ok) {
|
|
1452
|
+
throw new Error(`Failed to upload media: ${basename(mediaUrl)}`);
|
|
1453
|
+
}
|
|
676
1454
|
}
|
|
677
|
-
// Send text if present
|
|
678
1455
|
if (payload.text) {
|
|
679
1456
|
const keyboard = extractKeyboardFromPayload(payload);
|
|
680
1457
|
await sendService.sendText(sendCtx, payload.text, keyboard ? { keyboard } : undefined);
|
|
681
1458
|
}
|
|
682
1459
|
},
|
|
683
1460
|
onReplyStart: async () => {
|
|
684
|
-
|
|
685
|
-
await sendService.sendTyping(sendCtx);
|
|
686
|
-
}
|
|
1461
|
+
await replyStatusHeartbeat.start();
|
|
687
1462
|
},
|
|
688
1463
|
onError: (err) => {
|
|
689
1464
|
logger.error('Error delivering reply to B24', err);
|
|
@@ -691,29 +1466,205 @@ export const bitrix24Plugin = {
|
|
|
691
1466
|
},
|
|
692
1467
|
});
|
|
693
1468
|
} catch (err) {
|
|
694
|
-
logger.error('Error dispatching message to agent', err);
|
|
1469
|
+
logger.error('Error dispatching message to agent', { senderId: msgCtx.senderId, chatId: msgCtx.chatId, error: err });
|
|
1470
|
+
} finally {
|
|
1471
|
+
replyStatusHeartbeat.stop();
|
|
695
1472
|
}
|
|
696
|
-
}
|
|
1473
|
+
} finally {
|
|
1474
|
+
await mediaService.cleanupDownloadedMedia(downloadedMedia.map((mediaItem) => mediaItem.path));
|
|
1475
|
+
}
|
|
1476
|
+
};
|
|
1477
|
+
const directTextCoalescer = new BufferedDirectMessageCoalescer({
|
|
1478
|
+
debounceMs: DIRECT_TEXT_COALESCE_DEBOUNCE_MS,
|
|
1479
|
+
maxWaitMs: DIRECT_TEXT_COALESCE_MAX_WAIT_MS,
|
|
1480
|
+
onFlush: processAllowedMessage,
|
|
1481
|
+
logger,
|
|
1482
|
+
});
|
|
1483
|
+
const maybeNotifyDeniedDialog = async (
|
|
1484
|
+
dialogId: string,
|
|
1485
|
+
language: string | undefined,
|
|
1486
|
+
sendCtx: SendContext,
|
|
1487
|
+
): Promise<void> => {
|
|
1488
|
+
const now = Date.now();
|
|
1489
|
+
const lastSentAt = deniedDialogs.get(dialogId) ?? 0;
|
|
1490
|
+
if ((now - lastSentAt) < ACCESS_DENIED_NOTICE_COOLDOWN_MS) {
|
|
1491
|
+
return;
|
|
1492
|
+
}
|
|
697
1493
|
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
1494
|
+
deniedDialogs.set(dialogId, now);
|
|
1495
|
+
try {
|
|
1496
|
+
await sendService.sendText(
|
|
1497
|
+
sendCtx,
|
|
1498
|
+
personalBotOwnerOnly(language),
|
|
1499
|
+
{ convertMarkdown: false },
|
|
1500
|
+
);
|
|
1501
|
+
} catch (err) {
|
|
1502
|
+
logger.warn('Failed to send access denied notice', err);
|
|
1503
|
+
}
|
|
1504
|
+
};
|
|
1505
|
+
|
|
1506
|
+
// Register slash commands (runs in background)
|
|
1507
|
+
ensureCommandsRegistered(api, config, bot, logger).catch((err) => {
|
|
1508
|
+
logger.warn('Command registration failed', err);
|
|
1509
|
+
});
|
|
1510
|
+
|
|
1511
|
+
if (botRegistration.isNew) {
|
|
1512
|
+
await sendInitialWelcomeToWebhookOwner({
|
|
1513
|
+
config,
|
|
1514
|
+
bot,
|
|
1515
|
+
sendService,
|
|
1516
|
+
language: botRegistration.language,
|
|
1517
|
+
welcomedDialogs,
|
|
1518
|
+
logger,
|
|
1519
|
+
});
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1522
|
+
const inboundHandler = new InboundHandler({
|
|
1523
|
+
config,
|
|
1524
|
+
logger,
|
|
1525
|
+
|
|
1526
|
+
onMessage: async (msgCtx: B24MsgContext) => {
|
|
1527
|
+
logger.info('Inbound message', {
|
|
1528
|
+
senderId: msgCtx.senderId,
|
|
1529
|
+
chatId: msgCtx.chatId,
|
|
1530
|
+
messageId: msgCtx.messageId,
|
|
1531
|
+
textLen: msgCtx.text.length,
|
|
1532
|
+
});
|
|
1533
|
+
|
|
1534
|
+
const pendingForwardContext = msgCtx.isForwarded
|
|
1535
|
+
? directTextCoalescer.take(ctx.accountId, msgCtx.chatId)
|
|
1536
|
+
: null;
|
|
1537
|
+
|
|
1538
|
+
if (msgCtx.isGroup) {
|
|
1539
|
+
logger.warn('Group chat is not supported, leaving chat', {
|
|
1540
|
+
chatId: msgCtx.chatId,
|
|
1541
|
+
senderId: msgCtx.senderId,
|
|
1542
|
+
});
|
|
1543
|
+
|
|
1544
|
+
try {
|
|
1545
|
+
await api.leaveChat(webhookUrl, bot, msgCtx.chatId);
|
|
1546
|
+
} catch (err) {
|
|
1547
|
+
logger.error('Failed to leave group chat after message', err);
|
|
1548
|
+
}
|
|
702
1549
|
return;
|
|
703
1550
|
}
|
|
704
1551
|
|
|
705
|
-
const
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
1552
|
+
const runtime = getBitrix24Runtime();
|
|
1553
|
+
|
|
1554
|
+
// Pairing-aware access control
|
|
1555
|
+
const sendCtx: SendContext = {
|
|
1556
|
+
webhookUrl,
|
|
1557
|
+
bot,
|
|
1558
|
+
dialogId: msgCtx.chatId,
|
|
1559
|
+
};
|
|
710
1560
|
|
|
711
|
-
const
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
1561
|
+
const accessResult = await checkAccessWithPairing({
|
|
1562
|
+
senderId: msgCtx.senderId,
|
|
1563
|
+
dialogId: msgCtx.chatId,
|
|
1564
|
+
isDirect: msgCtx.isDm,
|
|
1565
|
+
config,
|
|
1566
|
+
runtime,
|
|
1567
|
+
accountId: ctx.accountId,
|
|
1568
|
+
pairingAdapter: bitrix24Plugin.pairing,
|
|
1569
|
+
sendReply: async (text: string) => {
|
|
1570
|
+
await sendService.sendText(sendCtx, text, { convertMarkdown: false });
|
|
1571
|
+
},
|
|
1572
|
+
logger,
|
|
1573
|
+
});
|
|
1574
|
+
|
|
1575
|
+
if (accessResult === 'deny') {
|
|
1576
|
+
await sendService.markRead(sendCtx, toMessageId(msgCtx.messageId));
|
|
1577
|
+
await maybeNotifyDeniedDialog(msgCtx.chatId, msgCtx.language, sendCtx);
|
|
1578
|
+
logger.debug('Message blocked (deny)', { senderId: msgCtx.senderId, chatId: msgCtx.chatId });
|
|
1579
|
+
return;
|
|
1580
|
+
}
|
|
715
1581
|
|
|
716
|
-
|
|
1582
|
+
await sendService.markRead(sendCtx, toMessageId(msgCtx.messageId));
|
|
1583
|
+
await sendService.sendTyping(sendCtx);
|
|
1584
|
+
|
|
1585
|
+
if (accessResult !== 'allow') {
|
|
1586
|
+
logger.debug(`Message blocked (${accessResult})`, { senderId: msgCtx.senderId });
|
|
1587
|
+
return;
|
|
1588
|
+
}
|
|
1589
|
+
|
|
1590
|
+
if (msgCtx.isForwarded) {
|
|
1591
|
+
if (pendingForwardContext) {
|
|
1592
|
+
logger.info('Merging forwarded message with buffered context', {
|
|
1593
|
+
senderId: msgCtx.senderId,
|
|
1594
|
+
chatId: msgCtx.chatId,
|
|
1595
|
+
previousMessageId: pendingForwardContext.messageId,
|
|
1596
|
+
forwardedMessageId: msgCtx.messageId,
|
|
1597
|
+
});
|
|
1598
|
+
await processAllowedMessage(mergeForwardedMessageContext(pendingForwardContext, msgCtx));
|
|
1599
|
+
return;
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1602
|
+
logger.info('Forwarded message is not supported yet', {
|
|
1603
|
+
senderId: msgCtx.senderId,
|
|
1604
|
+
chatId: msgCtx.chatId,
|
|
1605
|
+
messageId: msgCtx.messageId,
|
|
1606
|
+
});
|
|
1607
|
+
await sendService.sendText(
|
|
1608
|
+
sendCtx,
|
|
1609
|
+
forwardedMessageUnsupported(msgCtx.language),
|
|
1610
|
+
{ convertMarkdown: false },
|
|
1611
|
+
);
|
|
1612
|
+
return;
|
|
1613
|
+
}
|
|
1614
|
+
|
|
1615
|
+
if (msgCtx.replyToMessageId) {
|
|
1616
|
+
logger.info('Reply-to-message is not supported yet', {
|
|
1617
|
+
senderId: msgCtx.senderId,
|
|
1618
|
+
chatId: msgCtx.chatId,
|
|
1619
|
+
messageId: msgCtx.messageId,
|
|
1620
|
+
replyToMessageId: msgCtx.replyToMessageId,
|
|
1621
|
+
});
|
|
1622
|
+
await sendService.sendText(
|
|
1623
|
+
sendCtx,
|
|
1624
|
+
replyMessageUnsupported(msgCtx.language),
|
|
1625
|
+
{ convertMarkdown: false },
|
|
1626
|
+
);
|
|
1627
|
+
return;
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1630
|
+
if (canCoalesceDirectMessage(msgCtx, config)) {
|
|
1631
|
+
directTextCoalescer.enqueue(ctx.accountId, msgCtx);
|
|
1632
|
+
return;
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1635
|
+
await directTextCoalescer.flush(ctx.accountId, msgCtx.chatId);
|
|
1636
|
+
await processAllowedMessage(msgCtx);
|
|
1637
|
+
},
|
|
1638
|
+
|
|
1639
|
+
onCommand: async (cmdCtx: FetchCommandContext) => {
|
|
1640
|
+
const {
|
|
1641
|
+
commandId,
|
|
1642
|
+
commandName,
|
|
1643
|
+
commandParams,
|
|
1644
|
+
commandText,
|
|
1645
|
+
senderId,
|
|
1646
|
+
dialogId,
|
|
1647
|
+
chatType,
|
|
1648
|
+
messageId,
|
|
1649
|
+
} = cmdCtx;
|
|
1650
|
+
const isDm = chatType === 'P';
|
|
1651
|
+
const peerId = isDm ? senderId : dialogId;
|
|
1652
|
+
|
|
1653
|
+
logger.info('Inbound command', {
|
|
1654
|
+
commandId,
|
|
1655
|
+
commandName,
|
|
1656
|
+
commandParams,
|
|
1657
|
+
commandText,
|
|
1658
|
+
senderId,
|
|
1659
|
+
dialogId,
|
|
1660
|
+
peerId,
|
|
1661
|
+
});
|
|
1662
|
+
|
|
1663
|
+
const sendCtx: SendContext = {
|
|
1664
|
+
webhookUrl,
|
|
1665
|
+
bot,
|
|
1666
|
+
dialogId: peerId,
|
|
1667
|
+
};
|
|
717
1668
|
|
|
718
1669
|
let runtime;
|
|
719
1670
|
let cfg;
|
|
@@ -725,16 +1676,35 @@ export const bitrix24Plugin = {
|
|
|
725
1676
|
return;
|
|
726
1677
|
}
|
|
727
1678
|
|
|
728
|
-
|
|
1679
|
+
const commandMessageId = toMessageId(messageId);
|
|
1680
|
+
const commandSendCtx: CommandSendContext | null = commandMessageId
|
|
1681
|
+
? {
|
|
1682
|
+
...sendCtx,
|
|
1683
|
+
commandId,
|
|
1684
|
+
messageId: commandMessageId,
|
|
1685
|
+
commandDialogId: dialogId,
|
|
1686
|
+
}
|
|
1687
|
+
: null;
|
|
1688
|
+
|
|
1689
|
+
// Access control
|
|
729
1690
|
let accessResult;
|
|
730
1691
|
try {
|
|
731
1692
|
accessResult = await checkAccessWithPairing({
|
|
732
1693
|
senderId,
|
|
1694
|
+
dialogId,
|
|
1695
|
+
isDirect: isDm,
|
|
733
1696
|
config,
|
|
734
1697
|
runtime,
|
|
735
1698
|
accountId: ctx.accountId,
|
|
736
1699
|
pairingAdapter: bitrix24Plugin.pairing,
|
|
737
|
-
sendReply: async () => {
|
|
1700
|
+
sendReply: async (text: string) => {
|
|
1701
|
+
if (commandSendCtx) {
|
|
1702
|
+
await sendService.answerCommandText(commandSendCtx, text, { convertMarkdown: false });
|
|
1703
|
+
return;
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1706
|
+
await sendService.sendText(sendCtx, text, { convertMarkdown: false });
|
|
1707
|
+
},
|
|
738
1708
|
logger,
|
|
739
1709
|
});
|
|
740
1710
|
} catch (err) {
|
|
@@ -742,24 +1712,54 @@ export const bitrix24Plugin = {
|
|
|
742
1712
|
return;
|
|
743
1713
|
}
|
|
744
1714
|
|
|
1715
|
+
if (!commandMessageId || !commandSendCtx) {
|
|
1716
|
+
logger.warn('Command event has invalid messageId, skipping response', { commandId, messageId, dialogId });
|
|
1717
|
+
return;
|
|
1718
|
+
}
|
|
1719
|
+
const canMarkRead = dialogId === peerId;
|
|
1720
|
+
|
|
1721
|
+
await sendService.sendStatus(sendCtx, 'IMBOT_AGENT_ACTION_THINKING', 8);
|
|
1722
|
+
|
|
1723
|
+
if (accessResult === 'deny') {
|
|
1724
|
+
if (canMarkRead) {
|
|
1725
|
+
await sendService.markRead(sendCtx, commandMessageId);
|
|
1726
|
+
}
|
|
1727
|
+
await sendService.answerCommandText(
|
|
1728
|
+
commandSendCtx,
|
|
1729
|
+
personalBotOwnerOnly(cmdCtx.language),
|
|
1730
|
+
{ convertMarkdown: false },
|
|
1731
|
+
);
|
|
1732
|
+
logger.debug('Command blocked (deny)', { senderId, dialogId });
|
|
1733
|
+
return;
|
|
1734
|
+
}
|
|
1735
|
+
|
|
1736
|
+
if (canMarkRead) {
|
|
1737
|
+
await sendService.markRead(sendCtx, commandMessageId);
|
|
1738
|
+
}
|
|
1739
|
+
|
|
745
1740
|
if (accessResult !== 'allow') {
|
|
746
1741
|
logger.debug(`Command blocked (${accessResult})`, { senderId });
|
|
747
1742
|
return;
|
|
748
1743
|
}
|
|
749
1744
|
|
|
750
|
-
|
|
1745
|
+
await directTextCoalescer.flush(ctx.accountId, peerId);
|
|
1746
|
+
|
|
1747
|
+
if (commandName === 'help' || commandName === 'commands') {
|
|
1748
|
+
await sendService.sendText(
|
|
1749
|
+
sendCtx,
|
|
1750
|
+
buildCommandsHelpText(cmdCtx.language, { concise: commandName === 'help' }),
|
|
1751
|
+
{ keyboard: DEFAULT_COMMAND_KEYBOARD, convertMarkdown: false },
|
|
1752
|
+
);
|
|
1753
|
+
return;
|
|
1754
|
+
}
|
|
751
1755
|
|
|
752
1756
|
const route = runtime.channel.routing.resolveAgentRoute({
|
|
753
1757
|
cfg,
|
|
754
1758
|
channel: 'bitrix24',
|
|
755
1759
|
accountId: ctx.accountId,
|
|
756
|
-
peer: { kind: isDm ? 'direct' : 'group', id:
|
|
1760
|
+
peer: { kind: isDm ? 'direct' : 'group', id: peerId },
|
|
757
1761
|
});
|
|
758
1762
|
|
|
759
|
-
logger.debug('Command route resolved', { sessionKey: route.sessionKey });
|
|
760
|
-
|
|
761
|
-
// Each command invocation gets a unique ephemeral session
|
|
762
|
-
// so the gateway doesn't treat it as "already handled".
|
|
763
1763
|
const slashSessionKey = `bitrix24:slash:${senderId}:${Date.now()}`;
|
|
764
1764
|
|
|
765
1765
|
const inboundCtx = runtime.channel.reply.finalizeInboundContext({
|
|
@@ -769,130 +1769,206 @@ export const bitrix24Plugin = {
|
|
|
769
1769
|
CommandBody: commandText,
|
|
770
1770
|
CommandAuthorized: true,
|
|
771
1771
|
CommandSource: 'native',
|
|
772
|
-
CommandTargetSessionKey: `${route.sessionKey}:bitrix24:${
|
|
773
|
-
From: `bitrix24:${
|
|
1772
|
+
CommandTargetSessionKey: `${route.sessionKey}:bitrix24:${peerId}`,
|
|
1773
|
+
From: `bitrix24:${peerId}`,
|
|
774
1774
|
To: `slash:${senderId}`,
|
|
775
1775
|
SessionKey: slashSessionKey,
|
|
776
1776
|
AccountId: route.accountId,
|
|
777
1777
|
ChatType: isDm ? 'direct' : 'group',
|
|
778
|
-
ConversationLabel:
|
|
779
|
-
SenderName:
|
|
1778
|
+
ConversationLabel: senderId,
|
|
1779
|
+
SenderName: senderId,
|
|
780
1780
|
SenderId: senderId,
|
|
781
1781
|
Provider: 'bitrix24',
|
|
782
1782
|
Surface: 'bitrix24',
|
|
783
|
-
MessageSid:
|
|
1783
|
+
MessageSid: messageId,
|
|
784
1784
|
Timestamp: Date.now(),
|
|
785
1785
|
WasMentioned: true,
|
|
786
1786
|
OriginatingChannel: 'bitrix24',
|
|
787
|
-
OriginatingTo: `bitrix24:${
|
|
1787
|
+
OriginatingTo: `bitrix24:${peerId}`,
|
|
788
1788
|
});
|
|
789
1789
|
|
|
790
|
-
const
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
logger.debug('Dispatching command to agent', { commandText, slashSessionKey });
|
|
1790
|
+
const replyStatusHeartbeat = createReplyStatusHeartbeat({
|
|
1791
|
+
sendService,
|
|
1792
|
+
sendCtx,
|
|
1793
|
+
config,
|
|
1794
|
+
});
|
|
1795
|
+
let commandReplyDelivered = false;
|
|
798
1796
|
|
|
799
1797
|
try {
|
|
1798
|
+
await replyStatusHeartbeat.start();
|
|
800
1799
|
await runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
801
1800
|
ctx: inboundCtx,
|
|
802
1801
|
cfg,
|
|
803
1802
|
dispatcherOptions: {
|
|
804
1803
|
deliver: async (payload) => {
|
|
805
|
-
|
|
806
|
-
hasText: !!payload.text,
|
|
807
|
-
textLen: payload.text?.length ?? 0,
|
|
808
|
-
hasMedia: !!(payload.mediaUrl || payload.mediaUrls?.length),
|
|
809
|
-
});
|
|
1804
|
+
await replyStatusHeartbeat.stopAndWait();
|
|
810
1805
|
if (payload.text) {
|
|
811
|
-
// Use agent-provided keyboard if any, otherwise re-attach default command keyboard
|
|
812
1806
|
const keyboard = extractKeyboardFromPayload(payload) ?? DEFAULT_COMMAND_KEYBOARD;
|
|
813
|
-
|
|
1807
|
+
const formattedPayload = normalizeCommandReplyPayload({
|
|
1808
|
+
commandName,
|
|
1809
|
+
commandParams,
|
|
1810
|
+
text: payload.text,
|
|
1811
|
+
language: cmdCtx.language,
|
|
1812
|
+
});
|
|
1813
|
+
if (!commandReplyDelivered) {
|
|
1814
|
+
commandReplyDelivered = true;
|
|
1815
|
+
if (isDm) {
|
|
1816
|
+
await sendService.sendText(sendCtx, formattedPayload.text, {
|
|
1817
|
+
keyboard,
|
|
1818
|
+
convertMarkdown: formattedPayload.convertMarkdown,
|
|
1819
|
+
});
|
|
1820
|
+
return;
|
|
1821
|
+
}
|
|
1822
|
+
|
|
1823
|
+
await sendService.answerCommandText(commandSendCtx, formattedPayload.text, {
|
|
1824
|
+
keyboard,
|
|
1825
|
+
convertMarkdown: formattedPayload.convertMarkdown,
|
|
1826
|
+
});
|
|
1827
|
+
return;
|
|
1828
|
+
}
|
|
1829
|
+
|
|
1830
|
+
await sendService.sendText(sendCtx, formattedPayload.text, {
|
|
1831
|
+
keyboard,
|
|
1832
|
+
convertMarkdown: formattedPayload.convertMarkdown,
|
|
1833
|
+
});
|
|
814
1834
|
}
|
|
815
1835
|
},
|
|
816
1836
|
onReplyStart: async () => {
|
|
817
|
-
|
|
818
|
-
await sendService.sendTyping(sendCtx);
|
|
819
|
-
}
|
|
1837
|
+
await replyStatusHeartbeat.start();
|
|
820
1838
|
},
|
|
821
1839
|
onError: (err) => {
|
|
822
1840
|
logger.error('Error delivering command reply to B24', err);
|
|
823
1841
|
},
|
|
824
1842
|
},
|
|
825
1843
|
});
|
|
826
|
-
logger.debug('Command dispatch completed', { commandText });
|
|
827
1844
|
} catch (err) {
|
|
828
|
-
logger.error('Error dispatching command to agent', err);
|
|
1845
|
+
logger.error('Error dispatching command to agent', { commandName, senderId, dialogId, error: err });
|
|
1846
|
+
} finally {
|
|
1847
|
+
replyStatusHeartbeat.stop();
|
|
829
1848
|
}
|
|
830
1849
|
},
|
|
831
1850
|
|
|
832
|
-
onJoinChat: async (
|
|
833
|
-
const dialogId =
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
dialogId,
|
|
837
|
-
userId: event.data.PARAMS.USER_ID,
|
|
838
|
-
hasBotEntry: !!botEntry,
|
|
839
|
-
botId: botEntry?.BOT_ID,
|
|
840
|
-
hasEndpoint: !!botEntry?.client_endpoint,
|
|
841
|
-
hasToken: !!botEntry?.access_token,
|
|
842
|
-
});
|
|
1851
|
+
onJoinChat: async (joinCtx: FetchJoinChatContext) => {
|
|
1852
|
+
const { dialogId, chatType, language } = joinCtx;
|
|
1853
|
+
logger.info('Bot joined chat', { dialogId, chatType });
|
|
1854
|
+
|
|
843
1855
|
if (!dialogId) return;
|
|
844
1856
|
|
|
845
|
-
|
|
846
|
-
|
|
1857
|
+
if (shouldSkipJoinChatWelcome({
|
|
1858
|
+
dialogId,
|
|
1859
|
+
chatType,
|
|
1860
|
+
webhookUrl,
|
|
1861
|
+
dmPolicy: config.dmPolicy,
|
|
1862
|
+
})) {
|
|
1863
|
+
logger.info('Skipping welcome for non-owner dialog in webhookUser mode', { dialogId });
|
|
1864
|
+
return;
|
|
1865
|
+
}
|
|
847
1866
|
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
await api.
|
|
860
|
-
}
|
|
861
|
-
logger.
|
|
862
|
-
return;
|
|
1867
|
+
const sendCtx: SendContext = {
|
|
1868
|
+
webhookUrl,
|
|
1869
|
+
bot,
|
|
1870
|
+
dialogId,
|
|
1871
|
+
};
|
|
1872
|
+
|
|
1873
|
+
// Reject group chats
|
|
1874
|
+
if (chatType === 'chat' || chatType === 'open') {
|
|
1875
|
+
logger.info('Group chat not supported, leaving', { dialogId });
|
|
1876
|
+
try {
|
|
1877
|
+
await sendService.sendText(sendCtx, groupChatUnsupported(language));
|
|
1878
|
+
await api.leaveChat(webhookUrl, bot, dialogId);
|
|
1879
|
+
} catch (err) {
|
|
1880
|
+
logger.error('Failed to leave group chat', err);
|
|
863
1881
|
}
|
|
1882
|
+
return;
|
|
1883
|
+
}
|
|
1884
|
+
|
|
1885
|
+
if (welcomedDialogs.has(dialogId)) {
|
|
1886
|
+
logger.info('Skipping duplicate welcome for already welcomed dialog', { dialogId });
|
|
1887
|
+
return;
|
|
1888
|
+
}
|
|
1889
|
+
|
|
1890
|
+
// Send welcome message
|
|
1891
|
+
const isPairing = config.dmPolicy === 'pairing';
|
|
1892
|
+
const text = onboardingMessage(language, config.botName ?? 'OpenClaw', config.dmPolicy);
|
|
1893
|
+
try {
|
|
1894
|
+
await sendService.sendText(
|
|
1895
|
+
sendCtx,
|
|
1896
|
+
text,
|
|
1897
|
+
isPairing ? undefined : { keyboard: DEFAULT_COMMAND_KEYBOARD },
|
|
1898
|
+
);
|
|
1899
|
+
welcomedDialogs.add(dialogId);
|
|
864
1900
|
logger.info('Welcome message sent', { dialogId });
|
|
865
|
-
} catch (err
|
|
866
|
-
|
|
867
|
-
logger.error('Failed to send welcome message', { error: errMsg, dialogId });
|
|
868
|
-
// Retry via webhook if token-based call failed
|
|
869
|
-
if (botEntry?.client_endpoint && config.webhookUrl) {
|
|
870
|
-
try {
|
|
871
|
-
await api.sendMessage(config.webhookUrl, dialogId, welcomeText, welcomeOpts);
|
|
872
|
-
logger.info('Welcome message sent via webhook fallback', { dialogId });
|
|
873
|
-
} catch (err2: unknown) {
|
|
874
|
-
const errMsg2 = err2 instanceof Error ? err2.message : String(err2);
|
|
875
|
-
logger.error('Welcome message webhook fallback also failed', { error: errMsg2, dialogId });
|
|
876
|
-
}
|
|
877
|
-
}
|
|
1901
|
+
} catch (err) {
|
|
1902
|
+
logger.error('Failed to send welcome message', err);
|
|
878
1903
|
}
|
|
879
1904
|
},
|
|
880
|
-
});
|
|
881
1905
|
|
|
882
|
-
|
|
1906
|
+
onBotDelete: async (_data: B24V2DeleteEventData) => {
|
|
1907
|
+
logger.info('Bot deleted from portal');
|
|
1908
|
+
},
|
|
1909
|
+
});
|
|
883
1910
|
|
|
884
|
-
|
|
1911
|
+
gatewayState = { accountId: ctx.accountId, api, bot, sendService, mediaService, inboundHandler, eventMode };
|
|
1912
|
+
|
|
1913
|
+
logger.info(`[${ctx.accountId}] Bitrix24 channel started (${eventMode} mode)`);
|
|
1914
|
+
|
|
1915
|
+
// ─── Mode-specific lifecycle ──────────────────────────────────
|
|
1916
|
+
|
|
1917
|
+
if (eventMode === 'fetch') {
|
|
1918
|
+
// FETCH mode: start polling loop (blocks until abort)
|
|
1919
|
+
const pollingService = new PollingService({
|
|
1920
|
+
api,
|
|
1921
|
+
webhookUrl,
|
|
1922
|
+
bot,
|
|
1923
|
+
accountId: ctx.accountId,
|
|
1924
|
+
pollingIntervalMs: config.pollingIntervalMs ?? 3000,
|
|
1925
|
+
pollingFastIntervalMs: config.pollingFastIntervalMs ?? 100,
|
|
1926
|
+
onEvent: async (event: B24V2FetchEventItem) => {
|
|
1927
|
+
const fetchCtx: FetchContext = {
|
|
1928
|
+
webhookUrl,
|
|
1929
|
+
botId: bot.botId,
|
|
1930
|
+
botToken: bot.botToken,
|
|
1931
|
+
};
|
|
1932
|
+
await inboundHandler.handleFetchEvent(event, fetchCtx);
|
|
1933
|
+
},
|
|
1934
|
+
abortSignal: ctx.abortSignal,
|
|
1935
|
+
logger,
|
|
1936
|
+
});
|
|
885
1937
|
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
logger.info(`[${ctx.accountId}] Bitrix24 channel stopping`);
|
|
1938
|
+
try {
|
|
1939
|
+
await pollingService.start();
|
|
1940
|
+
} finally {
|
|
1941
|
+
logger.info(`[${ctx.accountId}] Bitrix24 channel stopping (fetch)`);
|
|
1942
|
+
clearInterval(deniedCleanupTimer);
|
|
1943
|
+
await directTextCoalescer.flushAll();
|
|
1944
|
+
directTextCoalescer.destroy();
|
|
890
1945
|
inboundHandler.destroy();
|
|
891
1946
|
api.destroy();
|
|
892
1947
|
gatewayState = null;
|
|
893
|
-
|
|
1948
|
+
}
|
|
1949
|
+
} else {
|
|
1950
|
+
// WEBHOOK mode: keep alive until abort signal
|
|
1951
|
+
return new Promise<void>((resolve) => {
|
|
1952
|
+
ctx.abortSignal.addEventListener('abort', () => {
|
|
1953
|
+
const cleanup = async (): Promise<void> => {
|
|
1954
|
+
logger.info(`[${ctx.accountId}] Bitrix24 channel stopping (webhook)`);
|
|
1955
|
+
clearInterval(deniedCleanupTimer);
|
|
1956
|
+
// Flush with timeout to prevent hanging
|
|
1957
|
+
await Promise.race([
|
|
1958
|
+
directTextCoalescer.flushAll(),
|
|
1959
|
+
new Promise<void>((r) => setTimeout(r, 5000)),
|
|
1960
|
+
]);
|
|
1961
|
+
directTextCoalescer.destroy();
|
|
1962
|
+
inboundHandler.destroy();
|
|
1963
|
+
api.destroy();
|
|
1964
|
+
gatewayState = null;
|
|
1965
|
+
};
|
|
1966
|
+
cleanup().catch((err) => {
|
|
1967
|
+
logger.error('Webhook cleanup error', err);
|
|
1968
|
+
}).finally(() => resolve());
|
|
1969
|
+
});
|
|
894
1970
|
});
|
|
895
|
-
}
|
|
1971
|
+
}
|
|
896
1972
|
},
|
|
897
1973
|
},
|
|
898
1974
|
};
|