@ihazz/bitrix24 0.2.4 → 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 -46
- package/src/api.ts +434 -232
- package/src/channel.ts +1486 -393
- 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 +279 -61
- 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,30 +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');
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
// ─── Outbound adapter helpers ────────────────────────────────────────────────
|
|
906
|
+
|
|
907
|
+
function resolveOutboundSendCtx(params: {
|
|
908
|
+
cfg: Record<string, unknown>;
|
|
909
|
+
to: string;
|
|
910
|
+
accountId?: string;
|
|
911
|
+
}): SendContext | null {
|
|
912
|
+
const { config } = resolveAccount(params.cfg, params.accountId);
|
|
913
|
+
if (!config.webhookUrl || !gatewayState) return null;
|
|
914
|
+
return {
|
|
915
|
+
webhookUrl: config.webhookUrl,
|
|
916
|
+
bot: gatewayState.bot,
|
|
917
|
+
dialogId: params.to,
|
|
918
|
+
};
|
|
919
|
+
}
|
|
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);
|
|
294
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;
|
|
295
976
|
}
|
|
296
977
|
|
|
297
978
|
/**
|
|
298
979
|
* The Bitrix24 channel plugin object.
|
|
299
|
-
*
|
|
300
|
-
* Implements the OpenClaw ChannelPlugin interface.
|
|
301
980
|
*/
|
|
302
981
|
export const bitrix24Plugin = {
|
|
303
982
|
id: 'bitrix24',
|
|
@@ -308,29 +987,25 @@ export const bitrix24Plugin = {
|
|
|
308
987
|
selectionLabel: 'Bitrix24 (Messenger)',
|
|
309
988
|
docsPath: '/channels/bitrix24',
|
|
310
989
|
docsLabel: 'bitrix24',
|
|
311
|
-
blurb: 'Connect to Bitrix24 Messenger via chat bot REST API.',
|
|
990
|
+
blurb: 'Connect to Bitrix24 Messenger via chat bot REST API (V2).',
|
|
312
991
|
aliases: ['b24', 'bx24'],
|
|
313
992
|
},
|
|
314
993
|
|
|
315
994
|
capabilities: {
|
|
316
|
-
chatTypes: ['direct'
|
|
995
|
+
chatTypes: ['direct'] as const,
|
|
317
996
|
media: true,
|
|
318
|
-
reactions:
|
|
997
|
+
reactions: true,
|
|
319
998
|
threads: false,
|
|
320
999
|
nativeCommands: true,
|
|
321
1000
|
inlineButtons: 'all',
|
|
322
1001
|
},
|
|
323
1002
|
|
|
324
1003
|
messaging: {
|
|
1004
|
+
normalizeTarget: (raw: string) => raw.trim().replace(CHANNEL_PREFIX_RE, ''),
|
|
325
1005
|
targetResolver: {
|
|
326
1006
|
hint: 'Use a numeric chat/dialog ID, e.g. "1" or "chat42".',
|
|
327
|
-
/**
|
|
328
|
-
* Recognize any numeric string as a valid Bitrix24 target ID.
|
|
329
|
-
* B24 dialog IDs can be short (e.g. "1"), so the default 6+ digit check is too strict.
|
|
330
|
-
*/
|
|
331
1007
|
looksLikeId: (raw: string, _normalized: string) => {
|
|
332
|
-
|
|
333
|
-
const stripped = raw.trim().replace(/^(bitrix24|b24|bx24):/i, '');
|
|
1008
|
+
const stripped = raw.trim().replace(CHANNEL_PREFIX_RE, '');
|
|
334
1009
|
return /^\d+$/.test(stripped);
|
|
335
1010
|
},
|
|
336
1011
|
},
|
|
@@ -343,16 +1018,20 @@ export const bitrix24Plugin = {
|
|
|
343
1018
|
},
|
|
344
1019
|
|
|
345
1020
|
security: {
|
|
346
|
-
resolveDmPolicy: (params: { cfg?: Record<string, unknown>; accountId?: string; account?: { config?: Record<string, unknown> } }) =>
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
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
|
+
},
|
|
354
1033
|
normalizeAllowFrom: (entry: string) =>
|
|
355
|
-
entry.replace(
|
|
1034
|
+
entry.replace(CHANNEL_PREFIX_RE, ''),
|
|
356
1035
|
},
|
|
357
1036
|
|
|
358
1037
|
pairing: {
|
|
@@ -360,113 +1039,209 @@ export const bitrix24Plugin = {
|
|
|
360
1039
|
normalizeAllowEntry: (entry: string) => normalizeAllowEntry(entry),
|
|
361
1040
|
notifyApproval: async (params: { cfg: Record<string, unknown>; id: string; runtime?: unknown }) => {
|
|
362
1041
|
const { config: acctCfg } = resolveAccount(params.cfg);
|
|
363
|
-
if (!acctCfg.webhookUrl) return;
|
|
364
|
-
const
|
|
1042
|
+
if (!acctCfg.webhookUrl || !gatewayState) return;
|
|
1043
|
+
const sendCtx: SendContext = {
|
|
1044
|
+
webhookUrl: acctCfg.webhookUrl,
|
|
1045
|
+
bot: gatewayState.bot,
|
|
1046
|
+
dialogId: params.id,
|
|
1047
|
+
};
|
|
365
1048
|
try {
|
|
366
|
-
await
|
|
367
|
-
}
|
|
368
|
-
|
|
1049
|
+
await gatewayState.sendService.sendText(sendCtx, '\u2705 OpenClaw access approved.');
|
|
1050
|
+
} catch (err) {
|
|
1051
|
+
defaultLogger.warn('Failed to notify approved Bitrix24 user', err);
|
|
369
1052
|
}
|
|
370
1053
|
},
|
|
371
1054
|
} satisfies ChannelPairingAdapter,
|
|
372
1055
|
|
|
373
1056
|
outbound: {
|
|
374
1057
|
deliveryMode: 'direct' as const,
|
|
1058
|
+
textChunkLimit: 4000,
|
|
375
1059
|
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
sendText: async (params: {
|
|
1060
|
+
sendText: async (ctx: {
|
|
1061
|
+
cfg: Record<string, unknown>;
|
|
1062
|
+
to: string;
|
|
1063
|
+
accountId?: string;
|
|
381
1064
|
text: string;
|
|
382
|
-
|
|
383
|
-
account: { config: { webhookUrl?: string; showTyping?: boolean } };
|
|
1065
|
+
[key: string]: unknown;
|
|
384
1066
|
}) => {
|
|
385
|
-
const
|
|
1067
|
+
const sendCtx = resolveOutboundSendCtx(ctx);
|
|
1068
|
+
if (!sendCtx || !gatewayState) throw new Error('Bitrix24 gateway not started');
|
|
1069
|
+
const result = await gatewayState.sendService.sendText(sendCtx, ctx.text);
|
|
1070
|
+
return { messageId: String(result.messageId ?? '') };
|
|
1071
|
+
},
|
|
386
1072
|
|
|
387
|
-
|
|
388
|
-
|
|
1073
|
+
sendMedia: async (ctx: {
|
|
1074
|
+
cfg: Record<string, unknown>;
|
|
1075
|
+
to: string;
|
|
1076
|
+
accountId?: string;
|
|
1077
|
+
text: string;
|
|
1078
|
+
mediaUrl?: string;
|
|
1079
|
+
[key: string]: unknown;
|
|
1080
|
+
}) => {
|
|
1081
|
+
const sendCtx = resolveOutboundSendCtx(ctx);
|
|
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 };
|
|
389
1092
|
}
|
|
390
1093
|
|
|
391
|
-
|
|
1094
|
+
if (ctx.text) {
|
|
1095
|
+
const result = await gatewayState.sendService.sendText(sendCtx, ctx.text);
|
|
1096
|
+
return { messageId: String(result.messageId ?? '') };
|
|
1097
|
+
}
|
|
1098
|
+
return { messageId: '' };
|
|
1099
|
+
},
|
|
392
1100
|
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
1101
|
+
sendPayload: async (ctx: {
|
|
1102
|
+
cfg: Record<string, unknown>;
|
|
1103
|
+
to: string;
|
|
1104
|
+
accountId?: string;
|
|
1105
|
+
text: string;
|
|
1106
|
+
mediaUrl?: string;
|
|
1107
|
+
payload?: { channelData?: Record<string, unknown>; [key: string]: unknown };
|
|
1108
|
+
[key: string]: unknown;
|
|
1109
|
+
}) => {
|
|
1110
|
+
const sendCtx = resolveOutboundSendCtx(ctx);
|
|
1111
|
+
if (!sendCtx || !gatewayState) throw new Error('Bitrix24 gateway not started');
|
|
1112
|
+
|
|
1113
|
+
const keyboard = ctx.payload?.channelData
|
|
1114
|
+
? extractKeyboardFromPayload({ channelData: ctx.payload.channelData })
|
|
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
|
+
}
|
|
399
1138
|
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
1139
|
+
if (ctx.text) {
|
|
1140
|
+
const result = await gatewayState.sendService.sendText(
|
|
1141
|
+
sendCtx,
|
|
1142
|
+
ctx.text,
|
|
1143
|
+
keyboard ? { keyboard } : undefined,
|
|
1144
|
+
);
|
|
1145
|
+
return { messageId: String(result.messageId ?? '') };
|
|
403
1146
|
}
|
|
1147
|
+
return { messageId: '' };
|
|
1148
|
+
},
|
|
1149
|
+
},
|
|
404
1150
|
|
|
405
|
-
|
|
406
|
-
const result = await sendService.sendText(sendCtx, text);
|
|
1151
|
+
// ─── Actions (agent-driven: reactions, etc.) ────────────────────────────
|
|
407
1152
|
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
channel: 'bitrix24' as const,
|
|
412
|
-
error: result.error,
|
|
413
|
-
};
|
|
1153
|
+
actions: {
|
|
1154
|
+
listActions: (_params: { cfg: Record<string, unknown> }): string[] => {
|
|
1155
|
+
return ['react'];
|
|
414
1156
|
},
|
|
415
1157
|
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
*/
|
|
420
|
-
sendPayload: async (params: {
|
|
421
|
-
text: string;
|
|
422
|
-
channelData?: Record<string, unknown>;
|
|
423
|
-
context: B24MsgContext;
|
|
424
|
-
account: { config: { webhookUrl?: string; showTyping?: boolean } };
|
|
425
|
-
}) => {
|
|
426
|
-
const { text, channelData, context, account } = params;
|
|
1158
|
+
supportsAction: (params: { action: string }): boolean => {
|
|
1159
|
+
return params.action === 'react';
|
|
1160
|
+
},
|
|
427
1161
|
|
|
428
|
-
|
|
429
|
-
|
|
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.' });
|
|
430
1181
|
}
|
|
431
1182
|
|
|
432
|
-
const
|
|
1183
|
+
const bot = gatewayState.bot;
|
|
1184
|
+
const api = gatewayState.api;
|
|
1185
|
+
const params = ctx.params;
|
|
433
1186
|
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
};
|
|
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);
|
|
440
1192
|
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
await sendService.sendTyping(sendCtx);
|
|
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.' });
|
|
444
1195
|
}
|
|
445
1196
|
|
|
446
|
-
|
|
447
|
-
const
|
|
448
|
-
|
|
449
|
-
|
|
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
|
+
}
|
|
450
1214
|
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
);
|
|
1215
|
+
// Add reaction
|
|
1216
|
+
if (!emoji) {
|
|
1217
|
+
return toolResult({ ok: false, reason: 'missing_emoji', hint: 'Emoji is required for Bitrix24 reactions.' });
|
|
1218
|
+
}
|
|
456
1219
|
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
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
|
+
}
|
|
463
1241
|
},
|
|
464
1242
|
},
|
|
465
1243
|
|
|
466
1244
|
gateway: {
|
|
467
|
-
/**
|
|
468
|
-
* Start a channel account. Called by OpenClaw for each configured account.
|
|
469
|
-
*/
|
|
470
1245
|
startAccount: async (ctx: {
|
|
471
1246
|
cfg: Record<string, unknown>;
|
|
472
1247
|
accountId: string;
|
|
@@ -477,117 +1252,142 @@ export const bitrix24Plugin = {
|
|
|
477
1252
|
setStatus?: (status: Record<string, unknown>) => void;
|
|
478
1253
|
}) => {
|
|
479
1254
|
const logger = ctx.log ?? defaultLogger;
|
|
480
|
-
|
|
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);
|
|
481
1265
|
|
|
482
1266
|
if (!config.webhookUrl) {
|
|
483
1267
|
logger.warn(`[${ctx.accountId}] no webhookUrl configured, skipping`);
|
|
484
1268
|
return;
|
|
485
1269
|
}
|
|
1270
|
+
// Safe to use without ! after this point
|
|
1271
|
+
const webhookUrl: string = config.webhookUrl;
|
|
486
1272
|
|
|
487
1273
|
logger.info(`[${ctx.accountId}] starting Bitrix24 channel`);
|
|
488
1274
|
|
|
489
|
-
|
|
490
|
-
const
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
const
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
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();
|
|
503
1292
|
}
|
|
504
1293
|
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
1294
|
+
// Determine event mode
|
|
1295
|
+
const eventMode = resolveEventMode(config);
|
|
1296
|
+
logger.info(`[${ctx.accountId}] event mode: ${eventMode}`);
|
|
508
1297
|
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
senderId: msgCtx.senderId,
|
|
512
|
-
chatId: msgCtx.chatId,
|
|
513
|
-
messageId: msgCtx.messageId,
|
|
514
|
-
textLen: msgCtx.text.length,
|
|
515
|
-
});
|
|
1298
|
+
// Register or update bot on the B24 portal (V2 API)
|
|
1299
|
+
const botRegistration = await ensureBotRegistered(api, config, botToken, eventMode, logger);
|
|
516
1300
|
|
|
517
|
-
|
|
518
|
-
|
|
1301
|
+
if (!botRegistration) {
|
|
1302
|
+
logger.error(`[${ctx.accountId}] bot registration failed, cannot start`);
|
|
1303
|
+
clearInterval(deniedCleanupTimer);
|
|
1304
|
+
api.destroy();
|
|
1305
|
+
return;
|
|
1306
|
+
}
|
|
519
1307
|
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
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,
|
|
523
1341
|
config,
|
|
524
|
-
runtime,
|
|
525
|
-
accountId: ctx.accountId,
|
|
526
|
-
pairingAdapter: bitrix24Plugin.pairing,
|
|
527
|
-
sendReply: async (text: string) => {
|
|
528
|
-
const replySendCtx = {
|
|
529
|
-
webhookUrl: config.webhookUrl,
|
|
530
|
-
clientEndpoint: msgCtx.clientEndpoint,
|
|
531
|
-
botToken: msgCtx.botToken,
|
|
532
|
-
dialogId: msgCtx.chatId,
|
|
533
|
-
};
|
|
534
|
-
await sendService.sendText(replySendCtx, text, { convertMarkdown: false });
|
|
535
|
-
},
|
|
536
|
-
logger,
|
|
537
1342
|
});
|
|
538
1343
|
|
|
539
|
-
if (accessResult !== 'allow') {
|
|
540
|
-
logger.debug(`Message blocked (${accessResult})`, { senderId: msgCtx.senderId });
|
|
541
|
-
return;
|
|
542
|
-
}
|
|
543
|
-
|
|
544
1344
|
// Download media files if present
|
|
545
1345
|
let mediaFields: Record<string, unknown> = {};
|
|
546
1346
|
if (msgCtx.media.length > 0) {
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
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
|
+
}),
|
|
558
1361
|
)).filter(Boolean) as DownloadedMedia[];
|
|
559
1362
|
|
|
560
|
-
if (
|
|
1363
|
+
if (downloadedMedia.length > 0) {
|
|
561
1364
|
mediaFields = {
|
|
562
|
-
MediaPath:
|
|
563
|
-
MediaType:
|
|
564
|
-
MediaUrl:
|
|
565
|
-
MediaPaths:
|
|
566
|
-
MediaUrls:
|
|
567
|
-
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),
|
|
568
1371
|
};
|
|
569
1372
|
} else {
|
|
570
|
-
|
|
571
|
-
const fileNames = msgCtx.media.map((m) => m.name).join(', ');
|
|
1373
|
+
const fileNames = msgCtx.media.map((mediaItem) => mediaItem.name).join(', ');
|
|
572
1374
|
logger.warn('All media downloads failed, notifying user', { fileNames });
|
|
573
|
-
const errSendCtx = {
|
|
574
|
-
webhookUrl: config.webhookUrl,
|
|
575
|
-
clientEndpoint: msgCtx.clientEndpoint,
|
|
576
|
-
botToken: msgCtx.botToken,
|
|
577
|
-
dialogId: msgCtx.chatId,
|
|
578
|
-
};
|
|
579
1375
|
await sendService.sendText(
|
|
580
|
-
|
|
581
|
-
|
|
1376
|
+
sendCtx,
|
|
1377
|
+
mediaDownloadFailed(msgCtx.language, fileNames),
|
|
582
1378
|
);
|
|
583
1379
|
return;
|
|
584
1380
|
}
|
|
1381
|
+
} else {
|
|
1382
|
+
await notifyStatus(sendService, sendCtx, config, 'IMBOT_AGENT_ACTION_ANALYZING');
|
|
1383
|
+
replyStatusHeartbeat.holdFor(PHASE_STATUS_DURATION_SECONDS);
|
|
585
1384
|
}
|
|
586
1385
|
|
|
587
1386
|
// Use placeholder body for media-only messages
|
|
588
1387
|
let body = msgCtx.text;
|
|
589
1388
|
if (!body && msgCtx.media.length > 0) {
|
|
590
|
-
const hasImage =
|
|
1389
|
+
const hasImage = downloadedMedia.some((mediaItem) => mediaItem.contentType.startsWith('image/'))
|
|
1390
|
+
|| msgCtx.media.some((mediaItem) => mediaItem.type === 'image');
|
|
591
1391
|
body = hasImage ? '<media:image>' : '<media:document>';
|
|
592
1392
|
}
|
|
593
1393
|
|
|
@@ -608,7 +1408,6 @@ export const bitrix24Plugin = {
|
|
|
608
1408
|
matchedBy: route.matchedBy,
|
|
609
1409
|
});
|
|
610
1410
|
|
|
611
|
-
// Build and finalize inbound context for OpenClaw agent
|
|
612
1411
|
const inboundCtx = runtime.channel.reply.finalizeInboundContext({
|
|
613
1412
|
Body: body,
|
|
614
1413
|
BodyForAgent: body,
|
|
@@ -632,41 +1431,34 @@ export const bitrix24Plugin = {
|
|
|
632
1431
|
...mediaFields,
|
|
633
1432
|
});
|
|
634
1433
|
|
|
635
|
-
const sendCtx = {
|
|
636
|
-
webhookUrl: config.webhookUrl,
|
|
637
|
-
clientEndpoint: msgCtx.clientEndpoint,
|
|
638
|
-
botToken: msgCtx.botToken,
|
|
639
|
-
dialogId: msgCtx.chatId,
|
|
640
|
-
};
|
|
641
|
-
|
|
642
|
-
// Dispatch to AI agent; deliver callback sends reply back to B24
|
|
643
1434
|
try {
|
|
644
1435
|
await runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
645
1436
|
ctx: inboundCtx,
|
|
646
1437
|
cfg,
|
|
647
1438
|
dispatcherOptions: {
|
|
648
1439
|
deliver: async (payload) => {
|
|
649
|
-
|
|
1440
|
+
await replyStatusHeartbeat.stopAndWait();
|
|
650
1441
|
const mediaUrls = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
|
|
651
1442
|
for (const mediaUrl of mediaUrls) {
|
|
652
|
-
await mediaService.uploadMediaToChat({
|
|
1443
|
+
const uploadResult = await mediaService.uploadMediaToChat({
|
|
653
1444
|
localPath: mediaUrl,
|
|
654
1445
|
fileName: basename(mediaUrl),
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
1446
|
+
webhookUrl,
|
|
1447
|
+
bot,
|
|
1448
|
+
dialogId: msgCtx.chatId,
|
|
658
1449
|
});
|
|
1450
|
+
|
|
1451
|
+
if (!uploadResult.ok) {
|
|
1452
|
+
throw new Error(`Failed to upload media: ${basename(mediaUrl)}`);
|
|
1453
|
+
}
|
|
659
1454
|
}
|
|
660
|
-
// Send text if present
|
|
661
1455
|
if (payload.text) {
|
|
662
1456
|
const keyboard = extractKeyboardFromPayload(payload);
|
|
663
1457
|
await sendService.sendText(sendCtx, payload.text, keyboard ? { keyboard } : undefined);
|
|
664
1458
|
}
|
|
665
1459
|
},
|
|
666
1460
|
onReplyStart: async () => {
|
|
667
|
-
|
|
668
|
-
await sendService.sendTyping(sendCtx);
|
|
669
|
-
}
|
|
1461
|
+
await replyStatusHeartbeat.start();
|
|
670
1462
|
},
|
|
671
1463
|
onError: (err) => {
|
|
672
1464
|
logger.error('Error delivering reply to B24', err);
|
|
@@ -674,29 +1466,205 @@ export const bitrix24Plugin = {
|
|
|
674
1466
|
},
|
|
675
1467
|
});
|
|
676
1468
|
} catch (err) {
|
|
677
|
-
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();
|
|
678
1472
|
}
|
|
679
|
-
}
|
|
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
|
+
}
|
|
1493
|
+
|
|
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;
|
|
680
1537
|
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
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
|
+
}
|
|
685
1549
|
return;
|
|
686
1550
|
}
|
|
687
1551
|
|
|
688
|
-
const
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
1552
|
+
const runtime = getBitrix24Runtime();
|
|
1553
|
+
|
|
1554
|
+
// Pairing-aware access control
|
|
1555
|
+
const sendCtx: SendContext = {
|
|
1556
|
+
webhookUrl,
|
|
1557
|
+
bot,
|
|
1558
|
+
dialogId: msgCtx.chatId,
|
|
1559
|
+
};
|
|
1560
|
+
|
|
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
|
+
});
|
|
693
1574
|
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
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
|
+
}
|
|
698
1581
|
|
|
699
|
-
|
|
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
|
+
};
|
|
700
1668
|
|
|
701
1669
|
let runtime;
|
|
702
1670
|
let cfg;
|
|
@@ -708,16 +1676,35 @@ export const bitrix24Plugin = {
|
|
|
708
1676
|
return;
|
|
709
1677
|
}
|
|
710
1678
|
|
|
711
|
-
|
|
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
|
|
712
1690
|
let accessResult;
|
|
713
1691
|
try {
|
|
714
1692
|
accessResult = await checkAccessWithPairing({
|
|
715
1693
|
senderId,
|
|
1694
|
+
dialogId,
|
|
1695
|
+
isDirect: isDm,
|
|
716
1696
|
config,
|
|
717
1697
|
runtime,
|
|
718
1698
|
accountId: ctx.accountId,
|
|
719
1699
|
pairingAdapter: bitrix24Plugin.pairing,
|
|
720
|
-
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
|
+
},
|
|
721
1708
|
logger,
|
|
722
1709
|
});
|
|
723
1710
|
} catch (err) {
|
|
@@ -725,24 +1712,54 @@ export const bitrix24Plugin = {
|
|
|
725
1712
|
return;
|
|
726
1713
|
}
|
|
727
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
|
+
|
|
728
1740
|
if (accessResult !== 'allow') {
|
|
729
1741
|
logger.debug(`Command blocked (${accessResult})`, { senderId });
|
|
730
1742
|
return;
|
|
731
1743
|
}
|
|
732
1744
|
|
|
733
|
-
|
|
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
|
+
}
|
|
734
1755
|
|
|
735
1756
|
const route = runtime.channel.routing.resolveAgentRoute({
|
|
736
1757
|
cfg,
|
|
737
1758
|
channel: 'bitrix24',
|
|
738
1759
|
accountId: ctx.accountId,
|
|
739
|
-
peer: { kind: isDm ? 'direct' : 'group', id:
|
|
1760
|
+
peer: { kind: isDm ? 'direct' : 'group', id: peerId },
|
|
740
1761
|
});
|
|
741
1762
|
|
|
742
|
-
logger.debug('Command route resolved', { sessionKey: route.sessionKey });
|
|
743
|
-
|
|
744
|
-
// Each command invocation gets a unique ephemeral session
|
|
745
|
-
// so the gateway doesn't treat it as "already handled".
|
|
746
1763
|
const slashSessionKey = `bitrix24:slash:${senderId}:${Date.now()}`;
|
|
747
1764
|
|
|
748
1765
|
const inboundCtx = runtime.channel.reply.finalizeInboundContext({
|
|
@@ -752,130 +1769,206 @@ export const bitrix24Plugin = {
|
|
|
752
1769
|
CommandBody: commandText,
|
|
753
1770
|
CommandAuthorized: true,
|
|
754
1771
|
CommandSource: 'native',
|
|
755
|
-
CommandTargetSessionKey: `${route.sessionKey}:bitrix24:${
|
|
756
|
-
From: `bitrix24:${
|
|
1772
|
+
CommandTargetSessionKey: `${route.sessionKey}:bitrix24:${peerId}`,
|
|
1773
|
+
From: `bitrix24:${peerId}`,
|
|
757
1774
|
To: `slash:${senderId}`,
|
|
758
1775
|
SessionKey: slashSessionKey,
|
|
759
1776
|
AccountId: route.accountId,
|
|
760
1777
|
ChatType: isDm ? 'direct' : 'group',
|
|
761
|
-
ConversationLabel:
|
|
762
|
-
SenderName:
|
|
1778
|
+
ConversationLabel: senderId,
|
|
1779
|
+
SenderName: senderId,
|
|
763
1780
|
SenderId: senderId,
|
|
764
1781
|
Provider: 'bitrix24',
|
|
765
1782
|
Surface: 'bitrix24',
|
|
766
|
-
MessageSid:
|
|
1783
|
+
MessageSid: messageId,
|
|
767
1784
|
Timestamp: Date.now(),
|
|
768
1785
|
WasMentioned: true,
|
|
769
1786
|
OriginatingChannel: 'bitrix24',
|
|
770
|
-
OriginatingTo: `bitrix24:${
|
|
1787
|
+
OriginatingTo: `bitrix24:${peerId}`,
|
|
771
1788
|
});
|
|
772
1789
|
|
|
773
|
-
const
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
logger.debug('Dispatching command to agent', { commandText, slashSessionKey });
|
|
1790
|
+
const replyStatusHeartbeat = createReplyStatusHeartbeat({
|
|
1791
|
+
sendService,
|
|
1792
|
+
sendCtx,
|
|
1793
|
+
config,
|
|
1794
|
+
});
|
|
1795
|
+
let commandReplyDelivered = false;
|
|
781
1796
|
|
|
782
1797
|
try {
|
|
1798
|
+
await replyStatusHeartbeat.start();
|
|
783
1799
|
await runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
784
1800
|
ctx: inboundCtx,
|
|
785
1801
|
cfg,
|
|
786
1802
|
dispatcherOptions: {
|
|
787
1803
|
deliver: async (payload) => {
|
|
788
|
-
|
|
789
|
-
hasText: !!payload.text,
|
|
790
|
-
textLen: payload.text?.length ?? 0,
|
|
791
|
-
hasMedia: !!(payload.mediaUrl || payload.mediaUrls?.length),
|
|
792
|
-
});
|
|
1804
|
+
await replyStatusHeartbeat.stopAndWait();
|
|
793
1805
|
if (payload.text) {
|
|
794
|
-
// Use agent-provided keyboard if any, otherwise re-attach default command keyboard
|
|
795
1806
|
const keyboard = extractKeyboardFromPayload(payload) ?? DEFAULT_COMMAND_KEYBOARD;
|
|
796
|
-
|
|
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
|
+
});
|
|
797
1834
|
}
|
|
798
1835
|
},
|
|
799
1836
|
onReplyStart: async () => {
|
|
800
|
-
|
|
801
|
-
await sendService.sendTyping(sendCtx);
|
|
802
|
-
}
|
|
1837
|
+
await replyStatusHeartbeat.start();
|
|
803
1838
|
},
|
|
804
1839
|
onError: (err) => {
|
|
805
1840
|
logger.error('Error delivering command reply to B24', err);
|
|
806
1841
|
},
|
|
807
1842
|
},
|
|
808
1843
|
});
|
|
809
|
-
logger.debug('Command dispatch completed', { commandText });
|
|
810
1844
|
} catch (err) {
|
|
811
|
-
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();
|
|
812
1848
|
}
|
|
813
1849
|
},
|
|
814
1850
|
|
|
815
|
-
onJoinChat: async (
|
|
816
|
-
const dialogId =
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
dialogId,
|
|
820
|
-
userId: event.data.PARAMS.USER_ID,
|
|
821
|
-
hasBotEntry: !!botEntry,
|
|
822
|
-
botId: botEntry?.BOT_ID,
|
|
823
|
-
hasEndpoint: !!botEntry?.client_endpoint,
|
|
824
|
-
hasToken: !!botEntry?.access_token,
|
|
825
|
-
});
|
|
1851
|
+
onJoinChat: async (joinCtx: FetchJoinChatContext) => {
|
|
1852
|
+
const { dialogId, chatType, language } = joinCtx;
|
|
1853
|
+
logger.info('Bot joined chat', { dialogId, chatType });
|
|
1854
|
+
|
|
826
1855
|
if (!dialogId) return;
|
|
827
1856
|
|
|
828
|
-
|
|
829
|
-
|
|
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
|
+
}
|
|
830
1866
|
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
await api.
|
|
843
|
-
}
|
|
844
|
-
logger.
|
|
845
|
-
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);
|
|
846
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);
|
|
847
1900
|
logger.info('Welcome message sent', { dialogId });
|
|
848
|
-
} catch (err
|
|
849
|
-
|
|
850
|
-
logger.error('Failed to send welcome message', { error: errMsg, dialogId });
|
|
851
|
-
// Retry via webhook if token-based call failed
|
|
852
|
-
if (botEntry?.client_endpoint && config.webhookUrl) {
|
|
853
|
-
try {
|
|
854
|
-
await api.sendMessage(config.webhookUrl, dialogId, welcomeText, welcomeOpts);
|
|
855
|
-
logger.info('Welcome message sent via webhook fallback', { dialogId });
|
|
856
|
-
} catch (err2: unknown) {
|
|
857
|
-
const errMsg2 = err2 instanceof Error ? err2.message : String(err2);
|
|
858
|
-
logger.error('Welcome message webhook fallback also failed', { error: errMsg2, dialogId });
|
|
859
|
-
}
|
|
860
|
-
}
|
|
1901
|
+
} catch (err) {
|
|
1902
|
+
logger.error('Failed to send welcome message', err);
|
|
861
1903
|
}
|
|
862
1904
|
},
|
|
863
|
-
});
|
|
864
1905
|
|
|
865
|
-
|
|
1906
|
+
onBotDelete: async (_data: B24V2DeleteEventData) => {
|
|
1907
|
+
logger.info('Bot deleted from portal');
|
|
1908
|
+
},
|
|
1909
|
+
});
|
|
866
1910
|
|
|
867
|
-
|
|
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
|
+
});
|
|
868
1937
|
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
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();
|
|
873
1945
|
inboundHandler.destroy();
|
|
874
1946
|
api.destroy();
|
|
875
1947
|
gatewayState = null;
|
|
876
|
-
|
|
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
|
+
});
|
|
877
1970
|
});
|
|
878
|
-
}
|
|
1971
|
+
}
|
|
879
1972
|
},
|
|
880
1973
|
},
|
|
881
1974
|
};
|