@ihazz/bitrix24 0.1.4 → 0.1.6
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 +12 -3
- package/index.ts +3 -2
- package/package.json +1 -1
- package/src/access-control.ts +61 -4
- package/src/api.ts +107 -0
- package/src/channel.ts +405 -12
- package/src/commands.ts +60 -0
- package/src/config-schema.ts +1 -2
- package/src/inbound-handler.ts +1 -9
- package/src/media-service.ts +186 -0
- package/src/message-utils.ts +21 -11
- package/src/runtime.ts +23 -0
- package/src/types.ts +11 -2
- package/tests/access-control.test.ts +178 -6
- package/tests/channel.test.ts +538 -0
- package/tests/inbound-handler.test.ts +4 -2
- package/tests/media-service.test.ts +224 -0
- package/tests/message-utils.test.ts +13 -16
package/src/channel.ts
CHANGED
|
@@ -1,12 +1,25 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
|
+
import { basename } from 'node:path';
|
|
2
3
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
3
4
|
import { listAccountIds, resolveAccount, getConfig } from './config.js';
|
|
4
5
|
import { Bitrix24Api } from './api.js';
|
|
5
6
|
import { SendService } from './send-service.js';
|
|
7
|
+
import { MediaService } from './media-service.js';
|
|
8
|
+
import type { DownloadedMedia } from './media-service.js';
|
|
6
9
|
import { InboundHandler } from './inbound-handler.js';
|
|
10
|
+
import { normalizeAllowEntry, checkAccessWithPairing } from './access-control.js';
|
|
7
11
|
import { defaultLogger } from './utils.js';
|
|
8
12
|
import { getBitrix24Runtime } from './runtime.js';
|
|
9
|
-
import type {
|
|
13
|
+
import type { ChannelPairingAdapter } from './runtime.js';
|
|
14
|
+
import { OPENCLAW_COMMANDS } from './commands.js';
|
|
15
|
+
import type {
|
|
16
|
+
B24MsgContext,
|
|
17
|
+
B24JoinChatEvent,
|
|
18
|
+
B24CommandEvent,
|
|
19
|
+
Bitrix24AccountConfig,
|
|
20
|
+
B24Keyboard,
|
|
21
|
+
KeyboardButton,
|
|
22
|
+
} from './types.js';
|
|
10
23
|
|
|
11
24
|
interface Logger {
|
|
12
25
|
info: (...args: unknown[]) => void;
|
|
@@ -19,11 +32,89 @@ interface Logger {
|
|
|
19
32
|
interface GatewayState {
|
|
20
33
|
api: Bitrix24Api;
|
|
21
34
|
sendService: SendService;
|
|
35
|
+
mediaService: MediaService;
|
|
22
36
|
inboundHandler: InboundHandler;
|
|
23
37
|
}
|
|
24
38
|
|
|
25
39
|
let gatewayState: GatewayState | null = null;
|
|
26
40
|
|
|
41
|
+
// ─── Keyboard / Button conversion ────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
/** Generic button format used by OpenClaw channelData. */
|
|
44
|
+
export interface ChannelButton {
|
|
45
|
+
text: string;
|
|
46
|
+
callback_data?: string;
|
|
47
|
+
style?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Convert OpenClaw button rows to B24 flat KEYBOARD array.
|
|
52
|
+
* Input: Array<Array<{ text, callback_data, style }>>
|
|
53
|
+
* Output: flat array with { TYPE: 'NEWLINE' } separators between rows.
|
|
54
|
+
*/
|
|
55
|
+
export function convertButtonsToKeyboard(rows: ChannelButton[][]): B24Keyboard {
|
|
56
|
+
const keyboard: B24Keyboard = [];
|
|
57
|
+
|
|
58
|
+
for (let i = 0; i < rows.length; i++) {
|
|
59
|
+
for (const btn of rows[i]) {
|
|
60
|
+
const b24Btn: KeyboardButton = { TEXT: btn.text, DISPLAY: 'LINE' };
|
|
61
|
+
|
|
62
|
+
if (btn.callback_data?.startsWith('/')) {
|
|
63
|
+
// Slash command — use COMMAND + COMMAND_PARAMS
|
|
64
|
+
const parts = btn.callback_data.substring(1).split(' ');
|
|
65
|
+
b24Btn.COMMAND = parts[0];
|
|
66
|
+
if (parts.length > 1) {
|
|
67
|
+
b24Btn.COMMAND_PARAMS = parts.slice(1).join(' ');
|
|
68
|
+
}
|
|
69
|
+
} else if (btn.callback_data) {
|
|
70
|
+
// Non-slash data — insert text into input via PUT action
|
|
71
|
+
b24Btn.ACTION = 'PUT';
|
|
72
|
+
b24Btn.ACTION_VALUE = btn.callback_data;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (btn.style === 'primary') {
|
|
76
|
+
b24Btn.BG_COLOR_TOKEN = 'primary';
|
|
77
|
+
} else if (btn.style === 'attention' || btn.style === 'danger') {
|
|
78
|
+
b24Btn.BG_COLOR_TOKEN = 'alert';
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
keyboard.push(b24Btn);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Add NEWLINE separator between rows (not after last row)
|
|
85
|
+
if (i < rows.length - 1) {
|
|
86
|
+
keyboard.push({ TYPE: 'NEWLINE' });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return keyboard;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Extract B24 keyboard from a dispatcher payload's channelData.
|
|
95
|
+
* Checks bitrix24-specific data first, then falls back to OpenClaw generic button format.
|
|
96
|
+
*/
|
|
97
|
+
export function extractKeyboardFromPayload(
|
|
98
|
+
payload: { channelData?: Record<string, unknown> },
|
|
99
|
+
): B24Keyboard | undefined {
|
|
100
|
+
const cd = payload.channelData;
|
|
101
|
+
if (!cd) return undefined;
|
|
102
|
+
|
|
103
|
+
// Direct B24 keyboard (future-proof: channelData.bitrix24.keyboard)
|
|
104
|
+
const b24Data = cd.bitrix24 as { keyboard?: B24Keyboard } | undefined;
|
|
105
|
+
if (b24Data?.keyboard?.length) {
|
|
106
|
+
return b24Data.keyboard;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Translate from OpenClaw generic button format (channelData.telegram key)
|
|
110
|
+
const tgData = cd.telegram as { buttons?: ChannelButton[][] } | undefined;
|
|
111
|
+
if (tgData?.buttons?.length) {
|
|
112
|
+
return convertButtonsToKeyboard(tgData.buttons);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return undefined;
|
|
116
|
+
}
|
|
117
|
+
|
|
27
118
|
/**
|
|
28
119
|
* Register or update the bot on the Bitrix24 portal.
|
|
29
120
|
* Checks imbot.bot.list first; if a bot with the same CODE exists, updates it.
|
|
@@ -82,6 +173,51 @@ async function ensureBotRegistered(
|
|
|
82
173
|
}
|
|
83
174
|
}
|
|
84
175
|
|
|
176
|
+
/**
|
|
177
|
+
* Register OpenClaw slash commands with the B24 bot.
|
|
178
|
+
* Runs in the background — errors are logged but don't block startup.
|
|
179
|
+
*/
|
|
180
|
+
async function ensureCommandsRegistered(
|
|
181
|
+
api: Bitrix24Api,
|
|
182
|
+
config: Bitrix24AccountConfig,
|
|
183
|
+
botId: number,
|
|
184
|
+
logger: Logger,
|
|
185
|
+
): Promise<void> {
|
|
186
|
+
const { webhookUrl, callbackUrl } = config;
|
|
187
|
+
if (!webhookUrl || !callbackUrl) return;
|
|
188
|
+
|
|
189
|
+
let registered = 0;
|
|
190
|
+
let skipped = 0;
|
|
191
|
+
|
|
192
|
+
for (const cmd of OPENCLAW_COMMANDS) {
|
|
193
|
+
try {
|
|
194
|
+
await api.registerCommand(webhookUrl, {
|
|
195
|
+
BOT_ID: botId,
|
|
196
|
+
COMMAND: cmd.command,
|
|
197
|
+
COMMON: 'N',
|
|
198
|
+
HIDDEN: 'N',
|
|
199
|
+
EXTRANET_SUPPORT: 'N',
|
|
200
|
+
LANG: [
|
|
201
|
+
{ LANGUAGE_ID: 'en', TITLE: cmd.en, ...(cmd.params ? { PARAMS: cmd.params } : {}) },
|
|
202
|
+
{ LANGUAGE_ID: 'ru', TITLE: cmd.ru, ...(cmd.params ? { PARAMS: cmd.params } : {}) },
|
|
203
|
+
],
|
|
204
|
+
EVENT_COMMAND_ADD: callbackUrl,
|
|
205
|
+
});
|
|
206
|
+
registered++;
|
|
207
|
+
} catch (err: unknown) {
|
|
208
|
+
// "WRONG_REQUEST" typically means command already exists
|
|
209
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
210
|
+
if (msg.includes('WRONG_REQUEST') || msg.includes('already')) {
|
|
211
|
+
skipped++;
|
|
212
|
+
} else {
|
|
213
|
+
logger.warn(`Failed to register command /${cmd.command}`, err);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
logger.info(`Commands sync: ${registered} registered, ${skipped} already existed (total ${OPENCLAW_COMMANDS.length})`);
|
|
219
|
+
}
|
|
220
|
+
|
|
85
221
|
/**
|
|
86
222
|
* Handle an incoming HTTP request on the webhook route.
|
|
87
223
|
* Called by the HTTP route registered in index.ts.
|
|
@@ -140,7 +276,7 @@ export const bitrix24Plugin = {
|
|
|
140
276
|
|
|
141
277
|
capabilities: {
|
|
142
278
|
chatTypes: ['direct', 'group'] as const,
|
|
143
|
-
media:
|
|
279
|
+
media: true,
|
|
144
280
|
reactions: false,
|
|
145
281
|
threads: false,
|
|
146
282
|
nativeCommands: true,
|
|
@@ -153,12 +289,33 @@ export const bitrix24Plugin = {
|
|
|
153
289
|
},
|
|
154
290
|
|
|
155
291
|
security: {
|
|
156
|
-
resolveDmPolicy: (
|
|
157
|
-
account
|
|
292
|
+
resolveDmPolicy: (params: { cfg?: Record<string, unknown>; accountId?: string; account?: { config?: Record<string, unknown> } }) => ({
|
|
293
|
+
policy: (params.account?.config?.dmPolicy as string) ?? 'pairing',
|
|
294
|
+
allowFrom: (params.account?.config?.allowFrom as string[]) ?? [],
|
|
295
|
+
policyPath: 'channels.bitrix24.dmPolicy',
|
|
296
|
+
allowFromPath: 'channels.bitrix24.',
|
|
297
|
+
approveHint: 'openclaw pairing approve bitrix24 <CODE>',
|
|
298
|
+
normalizeEntry: (raw: string) => raw.replace(/^(bitrix24|b24|bx24):/i, ''),
|
|
299
|
+
}),
|
|
158
300
|
normalizeAllowFrom: (entry: string) =>
|
|
159
|
-
entry.replace(/^(bitrix24|b24|bx24)
|
|
301
|
+
entry.replace(/^(bitrix24|b24|bx24):/i, ''),
|
|
160
302
|
},
|
|
161
303
|
|
|
304
|
+
pairing: {
|
|
305
|
+
idLabel: 'bitrix24UserId',
|
|
306
|
+
normalizeAllowEntry: (entry: string) => normalizeAllowEntry(entry),
|
|
307
|
+
notifyApproval: async (params: { cfg: Record<string, unknown>; id: string; runtime?: unknown }) => {
|
|
308
|
+
const { config: acctCfg } = resolveAccount(params.cfg);
|
|
309
|
+
if (!acctCfg.webhookUrl) return;
|
|
310
|
+
const api = new Bitrix24Api();
|
|
311
|
+
try {
|
|
312
|
+
await api.sendMessage(acctCfg.webhookUrl, params.id, '\u2705 OpenClaw access approved.');
|
|
313
|
+
} finally {
|
|
314
|
+
api.destroy();
|
|
315
|
+
}
|
|
316
|
+
},
|
|
317
|
+
} satisfies ChannelPairingAdapter,
|
|
318
|
+
|
|
162
319
|
outbound: {
|
|
163
320
|
deliveryMode: 'direct' as const,
|
|
164
321
|
|
|
@@ -201,6 +358,55 @@ export const bitrix24Plugin = {
|
|
|
201
358
|
error: result.error,
|
|
202
359
|
};
|
|
203
360
|
},
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Send a payload with optional channelData (keyboards, etc.) to B24.
|
|
364
|
+
* Called by OpenClaw when the response includes channelData.
|
|
365
|
+
*/
|
|
366
|
+
sendPayload: async (params: {
|
|
367
|
+
text: string;
|
|
368
|
+
channelData?: Record<string, unknown>;
|
|
369
|
+
context: B24MsgContext;
|
|
370
|
+
account: { config: { webhookUrl?: string; showTyping?: boolean } };
|
|
371
|
+
}) => {
|
|
372
|
+
const { text, channelData, context, account } = params;
|
|
373
|
+
|
|
374
|
+
if (!gatewayState) {
|
|
375
|
+
return { ok: false, error: 'Gateway not started', channel: 'bitrix24' };
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const { sendService } = gatewayState;
|
|
379
|
+
|
|
380
|
+
const sendCtx = {
|
|
381
|
+
webhookUrl: account.config.webhookUrl,
|
|
382
|
+
clientEndpoint: context.clientEndpoint,
|
|
383
|
+
botToken: context.botToken,
|
|
384
|
+
dialogId: context.chatId,
|
|
385
|
+
};
|
|
386
|
+
|
|
387
|
+
// Send typing indicator
|
|
388
|
+
if (account.config.showTyping !== false) {
|
|
389
|
+
await sendService.sendTyping(sendCtx);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Extract keyboard from channelData
|
|
393
|
+
const keyboard = channelData
|
|
394
|
+
? extractKeyboardFromPayload({ channelData })
|
|
395
|
+
: undefined;
|
|
396
|
+
|
|
397
|
+
const result = await sendService.sendText(
|
|
398
|
+
sendCtx,
|
|
399
|
+
text,
|
|
400
|
+
keyboard ? { keyboard } : undefined,
|
|
401
|
+
);
|
|
402
|
+
|
|
403
|
+
return {
|
|
404
|
+
ok: result.ok,
|
|
405
|
+
messageId: result.messageId,
|
|
406
|
+
channel: 'bitrix24' as const,
|
|
407
|
+
error: result.error,
|
|
408
|
+
};
|
|
409
|
+
},
|
|
204
410
|
},
|
|
205
411
|
|
|
206
412
|
gateway: {
|
|
@@ -230,9 +436,17 @@ export const bitrix24Plugin = {
|
|
|
230
436
|
const clientId = createHash('md5').update(config.webhookUrl).digest('hex');
|
|
231
437
|
const api = new Bitrix24Api({ logger, clientId });
|
|
232
438
|
const sendService = new SendService(api, logger);
|
|
439
|
+
const mediaService = new MediaService(api, logger);
|
|
233
440
|
|
|
234
441
|
// Register or update bot on the B24 portal
|
|
235
|
-
await ensureBotRegistered(api, config, logger);
|
|
442
|
+
const botId = await ensureBotRegistered(api, config, logger);
|
|
443
|
+
|
|
444
|
+
// Register slash commands (runs in background, doesn't block startup)
|
|
445
|
+
if (botId) {
|
|
446
|
+
ensureCommandsRegistered(api, config, botId, logger).catch((err) => {
|
|
447
|
+
logger.warn('Command registration failed', err);
|
|
448
|
+
});
|
|
449
|
+
}
|
|
236
450
|
|
|
237
451
|
const inboundHandler = new InboundHandler({
|
|
238
452
|
config,
|
|
@@ -249,6 +463,64 @@ export const bitrix24Plugin = {
|
|
|
249
463
|
const runtime = getBitrix24Runtime();
|
|
250
464
|
const cfg = runtime.config.loadConfig();
|
|
251
465
|
|
|
466
|
+
// Pairing-aware access control
|
|
467
|
+
const accessResult = await checkAccessWithPairing({
|
|
468
|
+
senderId: msgCtx.senderId,
|
|
469
|
+
config,
|
|
470
|
+
runtime,
|
|
471
|
+
accountId: ctx.accountId,
|
|
472
|
+
pairingAdapter: bitrix24Plugin.pairing,
|
|
473
|
+
sendReply: async (text: string) => {
|
|
474
|
+
const replySendCtx = {
|
|
475
|
+
webhookUrl: config.webhookUrl,
|
|
476
|
+
clientEndpoint: msgCtx.clientEndpoint,
|
|
477
|
+
botToken: msgCtx.botToken,
|
|
478
|
+
dialogId: msgCtx.chatId,
|
|
479
|
+
};
|
|
480
|
+
await sendService.sendText(replySendCtx, text, { convertMarkdown: false });
|
|
481
|
+
},
|
|
482
|
+
logger,
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
if (accessResult !== 'allow') {
|
|
486
|
+
logger.debug(`Message blocked (${accessResult})`, { senderId: msgCtx.senderId });
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// Download media files if present
|
|
491
|
+
let mediaFields: Record<string, unknown> = {};
|
|
492
|
+
if (msgCtx.media.length > 0) {
|
|
493
|
+
const downloaded = (await Promise.all(
|
|
494
|
+
msgCtx.media.map((m) =>
|
|
495
|
+
mediaService.downloadMedia({
|
|
496
|
+
fileId: m.id,
|
|
497
|
+
fileName: m.name,
|
|
498
|
+
extension: m.extension,
|
|
499
|
+
clientEndpoint: msgCtx.clientEndpoint,
|
|
500
|
+
userToken: msgCtx.userToken,
|
|
501
|
+
}),
|
|
502
|
+
),
|
|
503
|
+
)).filter(Boolean) as DownloadedMedia[];
|
|
504
|
+
|
|
505
|
+
if (downloaded.length > 0) {
|
|
506
|
+
mediaFields = {
|
|
507
|
+
MediaPath: downloaded[0].path,
|
|
508
|
+
MediaType: downloaded[0].contentType,
|
|
509
|
+
MediaUrl: downloaded[0].path,
|
|
510
|
+
MediaPaths: downloaded.map((m) => m.path),
|
|
511
|
+
MediaUrls: downloaded.map((m) => m.path),
|
|
512
|
+
MediaTypes: downloaded.map((m) => m.contentType),
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// Use placeholder body for media-only messages
|
|
518
|
+
let body = msgCtx.text;
|
|
519
|
+
if (!body && msgCtx.media.length > 0) {
|
|
520
|
+
const hasImage = msgCtx.media.some((m) => m.type === 'image');
|
|
521
|
+
body = hasImage ? '<media:image>' : '<media:document>';
|
|
522
|
+
}
|
|
523
|
+
|
|
252
524
|
// Resolve which agent handles this conversation
|
|
253
525
|
const route = runtime.channel.routing.resolveAgentRoute({
|
|
254
526
|
cfg,
|
|
@@ -268,9 +540,9 @@ export const bitrix24Plugin = {
|
|
|
268
540
|
|
|
269
541
|
// Build and finalize inbound context for OpenClaw agent
|
|
270
542
|
const inboundCtx = runtime.channel.reply.finalizeInboundContext({
|
|
271
|
-
Body:
|
|
272
|
-
BodyForAgent:
|
|
273
|
-
RawBody:
|
|
543
|
+
Body: body,
|
|
544
|
+
BodyForAgent: body,
|
|
545
|
+
RawBody: body,
|
|
274
546
|
From: `bitrix24:${msgCtx.chatId}`,
|
|
275
547
|
To: `bitrix24:${msgCtx.chatId}`,
|
|
276
548
|
SessionKey: route.sessionKey,
|
|
@@ -284,9 +556,10 @@ export const bitrix24Plugin = {
|
|
|
284
556
|
MessageSid: msgCtx.messageId,
|
|
285
557
|
Timestamp: Date.now(),
|
|
286
558
|
WasMentioned: false,
|
|
287
|
-
CommandAuthorized:
|
|
559
|
+
CommandAuthorized: true,
|
|
288
560
|
OriginatingChannel: 'bitrix24',
|
|
289
561
|
OriginatingTo: `bitrix24:${msgCtx.chatId}`,
|
|
562
|
+
...mediaFields,
|
|
290
563
|
});
|
|
291
564
|
|
|
292
565
|
const sendCtx = {
|
|
@@ -303,8 +576,21 @@ export const bitrix24Plugin = {
|
|
|
303
576
|
cfg,
|
|
304
577
|
dispatcherOptions: {
|
|
305
578
|
deliver: async (payload) => {
|
|
579
|
+
// Send media if present in reply
|
|
580
|
+
const mediaUrls = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
|
|
581
|
+
for (const mediaUrl of mediaUrls) {
|
|
582
|
+
await mediaService.uploadMediaToChat({
|
|
583
|
+
localPath: mediaUrl,
|
|
584
|
+
fileName: basename(mediaUrl),
|
|
585
|
+
chatId: Number(msgCtx.chatInternalId),
|
|
586
|
+
clientEndpoint: msgCtx.clientEndpoint,
|
|
587
|
+
botToken: msgCtx.botToken,
|
|
588
|
+
});
|
|
589
|
+
}
|
|
590
|
+
// Send text if present
|
|
306
591
|
if (payload.text) {
|
|
307
|
-
|
|
592
|
+
const keyboard = extractKeyboardFromPayload(payload);
|
|
593
|
+
await sendService.sendText(sendCtx, payload.text, keyboard ? { keyboard } : undefined);
|
|
308
594
|
}
|
|
309
595
|
},
|
|
310
596
|
onReplyStart: async () => {
|
|
@@ -322,6 +608,113 @@ export const bitrix24Plugin = {
|
|
|
322
608
|
}
|
|
323
609
|
},
|
|
324
610
|
|
|
611
|
+
onCommand: async (event: B24CommandEvent) => {
|
|
612
|
+
const cmdEntry = Object.values(event.data.COMMAND)[0];
|
|
613
|
+
if (!cmdEntry) {
|
|
614
|
+
logger.warn('No command entry in ONIMCOMMANDADD event');
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
const commandName = cmdEntry.COMMAND;
|
|
619
|
+
const commandParams = cmdEntry.COMMAND_PARAMS?.trim() ?? '';
|
|
620
|
+
const commandText = commandParams
|
|
621
|
+
? `/${commandName} ${commandParams}`
|
|
622
|
+
: `/${commandName}`;
|
|
623
|
+
|
|
624
|
+
const senderId = String(event.data.PARAMS.FROM_USER_ID);
|
|
625
|
+
const dialogId = event.data.PARAMS.DIALOG_ID;
|
|
626
|
+
const isDm = event.data.PARAMS.CHAT_TYPE === 'P';
|
|
627
|
+
const user = event.data.USER;
|
|
628
|
+
|
|
629
|
+
logger.info('Inbound command', { commandName, commandParams, senderId, dialogId });
|
|
630
|
+
|
|
631
|
+
const runtime = getBitrix24Runtime();
|
|
632
|
+
const cfg = runtime.config.loadConfig();
|
|
633
|
+
|
|
634
|
+
// Pairing-aware access control (commands don't send pairing replies)
|
|
635
|
+
const accessResult = await checkAccessWithPairing({
|
|
636
|
+
senderId,
|
|
637
|
+
config,
|
|
638
|
+
runtime,
|
|
639
|
+
accountId: ctx.accountId,
|
|
640
|
+
pairingAdapter: bitrix24Plugin.pairing,
|
|
641
|
+
sendReply: async () => {},
|
|
642
|
+
logger,
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
if (accessResult !== 'allow') {
|
|
646
|
+
logger.debug(`Command blocked (${accessResult})`, { senderId });
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
const route = runtime.channel.routing.resolveAgentRoute({
|
|
651
|
+
cfg,
|
|
652
|
+
channel: 'bitrix24',
|
|
653
|
+
accountId: ctx.accountId,
|
|
654
|
+
peer: { kind: isDm ? 'direct' : 'group', id: dialogId },
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
// Native commands use a separate slash-command session
|
|
658
|
+
const slashSessionKey = `bitrix24:slash:${senderId}`;
|
|
659
|
+
|
|
660
|
+
const inboundCtx = runtime.channel.reply.finalizeInboundContext({
|
|
661
|
+
Body: commandText,
|
|
662
|
+
BodyForAgent: commandText,
|
|
663
|
+
RawBody: commandText,
|
|
664
|
+
CommandBody: commandText,
|
|
665
|
+
CommandAuthorized: true,
|
|
666
|
+
CommandSource: 'native',
|
|
667
|
+
CommandTargetSessionKey: route.sessionKey,
|
|
668
|
+
From: `bitrix24:${dialogId}`,
|
|
669
|
+
To: `slash:${senderId}`,
|
|
670
|
+
SessionKey: slashSessionKey,
|
|
671
|
+
AccountId: route.accountId,
|
|
672
|
+
ChatType: isDm ? 'direct' : 'group',
|
|
673
|
+
ConversationLabel: user.NAME,
|
|
674
|
+
SenderName: user.NAME,
|
|
675
|
+
SenderId: senderId,
|
|
676
|
+
Provider: 'bitrix24',
|
|
677
|
+
Surface: 'bitrix24',
|
|
678
|
+
MessageSid: String(event.data.PARAMS.MESSAGE_ID),
|
|
679
|
+
Timestamp: Date.now(),
|
|
680
|
+
WasMentioned: true,
|
|
681
|
+
OriginatingChannel: 'bitrix24',
|
|
682
|
+
OriginatingTo: `bitrix24:${dialogId}`,
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
const sendCtx = {
|
|
686
|
+
webhookUrl: config.webhookUrl,
|
|
687
|
+
clientEndpoint: (cmdEntry.client_endpoint as string | undefined) ?? event.auth.client_endpoint,
|
|
688
|
+
botToken: cmdEntry.access_token,
|
|
689
|
+
dialogId,
|
|
690
|
+
};
|
|
691
|
+
|
|
692
|
+
try {
|
|
693
|
+
await runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
694
|
+
ctx: inboundCtx,
|
|
695
|
+
cfg,
|
|
696
|
+
dispatcherOptions: {
|
|
697
|
+
deliver: async (payload) => {
|
|
698
|
+
if (payload.text) {
|
|
699
|
+
const keyboard = extractKeyboardFromPayload(payload);
|
|
700
|
+
await sendService.sendText(sendCtx, payload.text, keyboard ? { keyboard } : undefined);
|
|
701
|
+
}
|
|
702
|
+
},
|
|
703
|
+
onReplyStart: async () => {
|
|
704
|
+
if (config.showTyping !== false) {
|
|
705
|
+
await sendService.sendTyping(sendCtx);
|
|
706
|
+
}
|
|
707
|
+
},
|
|
708
|
+
onError: (err) => {
|
|
709
|
+
logger.error('Error delivering command reply to B24', err);
|
|
710
|
+
},
|
|
711
|
+
},
|
|
712
|
+
});
|
|
713
|
+
} catch (err) {
|
|
714
|
+
logger.error('Error dispatching command to agent', err);
|
|
715
|
+
}
|
|
716
|
+
},
|
|
717
|
+
|
|
325
718
|
onJoinChat: async (event: B24JoinChatEvent) => {
|
|
326
719
|
logger.info('Bot joined chat', {
|
|
327
720
|
dialogId: event.data.PARAMS.DIALOG_ID,
|
|
@@ -344,7 +737,7 @@ export const bitrix24Plugin = {
|
|
|
344
737
|
},
|
|
345
738
|
});
|
|
346
739
|
|
|
347
|
-
gatewayState = { api, sendService, inboundHandler };
|
|
740
|
+
gatewayState = { api, sendService, mediaService, inboundHandler };
|
|
348
741
|
|
|
349
742
|
logger.info(`[${ctx.accountId}] Bitrix24 channel started`);
|
|
350
743
|
|
package/src/commands.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenClaw bot commands to register with Bitrix24.
|
|
3
|
+
*
|
|
4
|
+
* Standard OpenClaw bot commands registered via imbot.command.register.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export interface BotCommandDef {
|
|
8
|
+
command: string;
|
|
9
|
+
en: string;
|
|
10
|
+
ru: string;
|
|
11
|
+
params?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Standard OpenClaw commands.
|
|
16
|
+
* Excludes: focus/unfocus/agents (Discord-specific), allowlist/bash (text-only scope).
|
|
17
|
+
*/
|
|
18
|
+
export const OPENCLAW_COMMANDS: BotCommandDef[] = [
|
|
19
|
+
// ── Status ──
|
|
20
|
+
{ command: 'help', en: 'Show available commands', ru: 'Показать доступные команды' },
|
|
21
|
+
{ command: 'commands', en: 'List all slash commands', ru: 'Список всех команд' },
|
|
22
|
+
{ command: 'status', en: 'Show current status', ru: 'Показать текущий статус' },
|
|
23
|
+
{ command: 'context', en: 'Explain how context is built', ru: 'Объяснить построение контекста' },
|
|
24
|
+
{ command: 'whoami', en: 'Show your sender ID', ru: 'Показать ваш ID' },
|
|
25
|
+
{ command: 'usage', en: 'Usage and cost summary', ru: 'Использование и стоимость', params: 'off|tokens|full|cost' },
|
|
26
|
+
|
|
27
|
+
// ── Session ──
|
|
28
|
+
{ command: 'new', en: 'Start a new session', ru: 'Начать новую сессию' },
|
|
29
|
+
{ command: 'reset', en: 'Reset the current session', ru: 'Сбросить текущую сессию' },
|
|
30
|
+
{ command: 'stop', en: 'Stop the current run', ru: 'Остановить текущий запуск' },
|
|
31
|
+
{ command: 'compact', en: 'Compact the session context', ru: 'Сжать контекст сессии', params: 'instructions' },
|
|
32
|
+
{ command: 'session', en: 'Manage session settings', ru: 'Настройки сессии', params: 'ttl|...' },
|
|
33
|
+
|
|
34
|
+
// ── Options ──
|
|
35
|
+
{ command: 'model', en: 'Show or set the model', ru: 'Показать/сменить модель', params: 'model name' },
|
|
36
|
+
{ command: 'models', en: 'List available models', ru: 'Список моделей', params: 'provider' },
|
|
37
|
+
{ command: 'think', en: 'Set thinking level', ru: 'Уровень размышлений', params: 'off|low|medium|high' },
|
|
38
|
+
{ command: 'verbose', en: 'Toggle verbose mode', ru: 'Подробный режим', params: 'on|off' },
|
|
39
|
+
{ command: 'reasoning', en: 'Toggle reasoning visibility', ru: 'Видимость рассуждений', params: 'on|off|stream' },
|
|
40
|
+
{ command: 'elevated', en: 'Toggle elevated mode', ru: 'Режим с расширенными правами', params: 'on|off|ask|full' },
|
|
41
|
+
{ command: 'exec', en: 'Set exec defaults', ru: 'Настройки выполнения', params: 'host|security|ask|node' },
|
|
42
|
+
{ command: 'queue', en: 'Adjust queue settings', ru: 'Настройки очереди', params: 'mode|debounce|cap|drop' },
|
|
43
|
+
|
|
44
|
+
// ── Management ──
|
|
45
|
+
{ command: 'config', en: 'Show or set config values', ru: 'Показать/задать конфигурацию', params: 'show|get|set|unset' },
|
|
46
|
+
{ command: 'debug', en: 'Set runtime debug overrides', ru: 'Отладочные настройки', params: 'show|reset|set|unset' },
|
|
47
|
+
{ command: 'approve', en: 'Approve or deny exec requests', ru: 'Одобрить/отклонить запросы' },
|
|
48
|
+
{ command: 'activation', en: 'Set group activation mode', ru: 'Режим активации в группах', params: 'mention|always' },
|
|
49
|
+
{ command: 'send', en: 'Set send policy', ru: 'Политика отправки', params: 'on|off|inherit' },
|
|
50
|
+
{ command: 'subagents', en: 'Manage subagent runs', ru: 'Управление субагентами' },
|
|
51
|
+
{ command: 'kill', en: 'Kill a running subagent', ru: 'Остановить субагента', params: 'id|all' },
|
|
52
|
+
{ command: 'steer', en: 'Send guidance to a subagent', ru: 'Направить субагента', params: 'message' },
|
|
53
|
+
|
|
54
|
+
// ── Tools ──
|
|
55
|
+
{ command: 'skill', en: 'Run a skill by name', ru: 'Запустить навык', params: 'name' },
|
|
56
|
+
{ command: 'restart', en: 'Restart OpenClaw', ru: 'Перезапустить OpenClaw' },
|
|
57
|
+
|
|
58
|
+
// ── Export ──
|
|
59
|
+
{ command: 'export-session', en: 'Export session to HTML', ru: 'Экспорт сессии в HTML' },
|
|
60
|
+
];
|
package/src/config-schema.ts
CHANGED
|
@@ -6,9 +6,8 @@ const AccountSchema = z.object({
|
|
|
6
6
|
botName: z.string().optional().default('OpenClaw'),
|
|
7
7
|
botCode: z.string().optional().default('openclaw'),
|
|
8
8
|
botAvatar: z.string().optional(),
|
|
9
|
-
callbackPath: z.string().optional().default('/hooks/bitrix24'),
|
|
10
9
|
callbackUrl: z.string().url().optional(),
|
|
11
|
-
dmPolicy: z.enum(['open', 'allowlist', 'pairing']).optional().default('
|
|
10
|
+
dmPolicy: z.enum(['open', 'allowlist', 'pairing']).optional().default('pairing'),
|
|
12
11
|
allowFrom: z.array(z.string()).optional(),
|
|
13
12
|
showTyping: z.boolean().optional().default(true),
|
|
14
13
|
streamUpdates: z.boolean().optional().default(false),
|
package/src/inbound-handler.ts
CHANGED
|
@@ -11,7 +11,6 @@ import type {
|
|
|
11
11
|
B24MediaItem,
|
|
12
12
|
} from './types.js';
|
|
13
13
|
import { Dedup } from './dedup.js';
|
|
14
|
-
import { checkAccess } from './access-control.js';
|
|
15
14
|
import type { Bitrix24AccountConfig } from './types.js';
|
|
16
15
|
import { defaultLogger } from './utils.js';
|
|
17
16
|
|
|
@@ -109,14 +108,6 @@ export class InboundHandler {
|
|
|
109
108
|
return true;
|
|
110
109
|
}
|
|
111
110
|
|
|
112
|
-
const senderId = String(params.FROM_USER_ID);
|
|
113
|
-
|
|
114
|
-
// Access control
|
|
115
|
-
if (!checkAccess(senderId, this.config)) {
|
|
116
|
-
this.logger.debug(`Access denied for user ${senderId}`);
|
|
117
|
-
return true;
|
|
118
|
-
}
|
|
119
|
-
|
|
120
111
|
// Extract bot entry
|
|
121
112
|
const botEntry = extractBotEntry(event.data.BOT);
|
|
122
113
|
if (!botEntry) {
|
|
@@ -183,5 +174,6 @@ function normalizeFiles(files?: Record<string, B24File>): B24MediaItem[] {
|
|
|
183
174
|
extension: file.extension,
|
|
184
175
|
size: file.size,
|
|
185
176
|
type: file.image ? 'image' as const : 'file' as const,
|
|
177
|
+
urlDownload: file.urlDownload || undefined,
|
|
186
178
|
}));
|
|
187
179
|
}
|