@ihazz/bitrix24 0.1.5 → 0.2.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/package.json +1 -1
- package/src/access-control.ts +61 -4
- package/src/api.ts +92 -0
- package/src/channel.ts +178 -28
- package/src/commands.ts +1 -1
- package/src/config-schema.ts +1 -1
- package/src/inbound-handler.ts +1 -9
- package/src/media-service.ts +195 -0
- package/src/runtime.ts +23 -0
- package/src/types.ts +1 -0
- 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/package.json
CHANGED
package/src/access-control.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import type { Bitrix24AccountConfig } from './types.js';
|
|
2
|
+
import type { PluginRuntime, ChannelPairingAdapter } from './runtime.js';
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Normalize an allowFrom entry — strip platform prefixes.
|
|
5
6
|
* "bitrix24:42" → "42", "b24:42" → "42", "bx24:42" → "42", "42" → "42"
|
|
6
7
|
*/
|
|
7
8
|
export function normalizeAllowEntry(entry: string): string {
|
|
8
|
-
return entry.trim().replace(/^(bitrix24|b24|bx24)
|
|
9
|
+
return entry.trim().replace(/^(bitrix24|b24|bx24):/i, '');
|
|
9
10
|
}
|
|
10
11
|
|
|
11
12
|
/**
|
|
@@ -17,7 +18,7 @@ export function checkAccess(
|
|
|
17
18
|
senderId: string,
|
|
18
19
|
config: Bitrix24AccountConfig,
|
|
19
20
|
): boolean {
|
|
20
|
-
const policy = config.dmPolicy ?? '
|
|
21
|
+
const policy = config.dmPolicy ?? 'pairing';
|
|
21
22
|
|
|
22
23
|
switch (policy) {
|
|
23
24
|
case 'open':
|
|
@@ -31,10 +32,66 @@ export function checkAccess(
|
|
|
31
32
|
}
|
|
32
33
|
|
|
33
34
|
case 'pairing':
|
|
34
|
-
// Pairing
|
|
35
|
-
return
|
|
35
|
+
// Pairing requires runtime — use checkAccessWithPairing() instead
|
|
36
|
+
return false;
|
|
36
37
|
|
|
37
38
|
default:
|
|
38
39
|
return false;
|
|
39
40
|
}
|
|
40
41
|
}
|
|
42
|
+
|
|
43
|
+
export type AccessResult = 'allow' | 'deny' | 'pairing';
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Pairing-aware access check.
|
|
47
|
+
* Merges config allowFrom with file-based allowFrom store.
|
|
48
|
+
* For pairing mode, upserts a pairing request and sends the reply.
|
|
49
|
+
*/
|
|
50
|
+
export async function checkAccessWithPairing(params: {
|
|
51
|
+
senderId: string;
|
|
52
|
+
config: Bitrix24AccountConfig;
|
|
53
|
+
runtime: PluginRuntime;
|
|
54
|
+
accountId: string;
|
|
55
|
+
pairingAdapter: ChannelPairingAdapter;
|
|
56
|
+
sendReply: (text: string) => Promise<void>;
|
|
57
|
+
logger?: { debug: (...args: unknown[]) => void };
|
|
58
|
+
}): Promise<AccessResult> {
|
|
59
|
+
const { senderId, config, runtime, accountId, pairingAdapter, sendReply, logger } = params;
|
|
60
|
+
const policy = config.dmPolicy ?? 'pairing';
|
|
61
|
+
|
|
62
|
+
if (policy === 'open') return 'allow';
|
|
63
|
+
|
|
64
|
+
// Read file-based allowFrom store and merge with config
|
|
65
|
+
const storeAllowFrom = await runtime.channel.pairing.readAllowFromStore('bitrix24', '', accountId);
|
|
66
|
+
const configAllowFrom = (config.allowFrom ?? []).map(normalizeAllowEntry);
|
|
67
|
+
const merged = [...new Set([...configAllowFrom, ...storeAllowFrom])];
|
|
68
|
+
const normalizedSender = normalizeAllowEntry(String(senderId));
|
|
69
|
+
|
|
70
|
+
if (merged.includes(normalizedSender)) return 'allow';
|
|
71
|
+
|
|
72
|
+
if (policy === 'allowlist') {
|
|
73
|
+
logger?.debug('Access denied (allowlist)', { senderId });
|
|
74
|
+
return 'deny';
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// policy === 'pairing'
|
|
78
|
+
const { code, created } = await runtime.channel.pairing.upsertPairingRequest({
|
|
79
|
+
channel: 'bitrix24',
|
|
80
|
+
id: senderId,
|
|
81
|
+
accountId,
|
|
82
|
+
meta: {},
|
|
83
|
+
pairingAdapter,
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
if (created) {
|
|
87
|
+
const reply = runtime.channel.pairing.buildPairingReply({
|
|
88
|
+
code,
|
|
89
|
+
channel: 'bitrix24',
|
|
90
|
+
accountId,
|
|
91
|
+
});
|
|
92
|
+
await sendReply(reply.text);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
logger?.debug('Pairing request handled', { senderId, code, created });
|
|
96
|
+
return 'pairing';
|
|
97
|
+
}
|
package/src/api.ts
CHANGED
|
@@ -287,6 +287,98 @@ export class Bitrix24Api {
|
|
|
287
287
|
return result.result;
|
|
288
288
|
}
|
|
289
289
|
|
|
290
|
+
// ─── File / Disk methods ─────────────────────────────────────────────
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Get file info including DOWNLOAD_URL.
|
|
294
|
+
* Uses the user's access token (file belongs to the user's disk).
|
|
295
|
+
*/
|
|
296
|
+
async getFileInfo(
|
|
297
|
+
clientEndpoint: string,
|
|
298
|
+
accessToken: string,
|
|
299
|
+
fileId: number,
|
|
300
|
+
): Promise<{ DOWNLOAD_URL: string; [key: string]: unknown }> {
|
|
301
|
+
const result = await this.callWithToken<{ DOWNLOAD_URL: string; [key: string]: unknown }>(
|
|
302
|
+
clientEndpoint,
|
|
303
|
+
'disk.file.get',
|
|
304
|
+
accessToken,
|
|
305
|
+
{ id: fileId },
|
|
306
|
+
);
|
|
307
|
+
return result.result;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
async getFileInfoViaWebhook(
|
|
311
|
+
webhookUrl: string,
|
|
312
|
+
fileId: number,
|
|
313
|
+
): Promise<{ DOWNLOAD_URL: string; [key: string]: unknown }> {
|
|
314
|
+
const result = await this.callWebhook<{ DOWNLOAD_URL: string; [key: string]: unknown }>(
|
|
315
|
+
webhookUrl,
|
|
316
|
+
'disk.file.get',
|
|
317
|
+
{ id: fileId },
|
|
318
|
+
);
|
|
319
|
+
return result.result;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Get the disk folder ID for a chat (needed for file uploads).
|
|
324
|
+
*/
|
|
325
|
+
async getChatFolder(
|
|
326
|
+
clientEndpoint: string,
|
|
327
|
+
accessToken: string,
|
|
328
|
+
chatId: number,
|
|
329
|
+
): Promise<number> {
|
|
330
|
+
const result = await this.callWithToken<{ ID: number; [key: string]: unknown }>(
|
|
331
|
+
clientEndpoint,
|
|
332
|
+
'im.disk.folder.get',
|
|
333
|
+
accessToken,
|
|
334
|
+
{ CHAT_ID: chatId },
|
|
335
|
+
);
|
|
336
|
+
return result.result.ID;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Upload a file to a disk folder (base64 encoded).
|
|
341
|
+
* Returns the disk file ID.
|
|
342
|
+
*/
|
|
343
|
+
async uploadFile(
|
|
344
|
+
clientEndpoint: string,
|
|
345
|
+
accessToken: string,
|
|
346
|
+
folderId: number,
|
|
347
|
+
fileName: string,
|
|
348
|
+
content: Buffer,
|
|
349
|
+
): Promise<number> {
|
|
350
|
+
const result = await this.callWithToken<{ ID: number; [key: string]: unknown }>(
|
|
351
|
+
clientEndpoint,
|
|
352
|
+
'disk.folder.uploadfile',
|
|
353
|
+
accessToken,
|
|
354
|
+
{
|
|
355
|
+
id: folderId,
|
|
356
|
+
data: { NAME: fileName },
|
|
357
|
+
fileContent: [fileName, content.toString('base64')],
|
|
358
|
+
generateUniqueName: true,
|
|
359
|
+
},
|
|
360
|
+
);
|
|
361
|
+
return result.result.ID;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Publish an uploaded file to a chat.
|
|
366
|
+
*/
|
|
367
|
+
async commitFileToChat(
|
|
368
|
+
clientEndpoint: string,
|
|
369
|
+
accessToken: string,
|
|
370
|
+
chatId: number,
|
|
371
|
+
diskId: number,
|
|
372
|
+
): Promise<boolean> {
|
|
373
|
+
const result = await this.callWithToken<boolean>(
|
|
374
|
+
clientEndpoint,
|
|
375
|
+
'im.disk.file.commit',
|
|
376
|
+
accessToken,
|
|
377
|
+
{ CHAT_ID: chatId, DISK_ID: diskId },
|
|
378
|
+
);
|
|
379
|
+
return result.result;
|
|
380
|
+
}
|
|
381
|
+
|
|
290
382
|
destroy(): void {
|
|
291
383
|
this.rateLimiter.destroy();
|
|
292
384
|
}
|
package/src/channel.ts
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
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';
|
|
7
|
-
import {
|
|
10
|
+
import { normalizeAllowEntry, checkAccessWithPairing } from './access-control.js';
|
|
8
11
|
import { defaultLogger } from './utils.js';
|
|
9
12
|
import { getBitrix24Runtime } from './runtime.js';
|
|
13
|
+
import type { ChannelPairingAdapter } from './runtime.js';
|
|
10
14
|
import { OPENCLAW_COMMANDS } from './commands.js';
|
|
11
15
|
import type {
|
|
12
16
|
B24MsgContext,
|
|
@@ -28,25 +32,39 @@ interface Logger {
|
|
|
28
32
|
interface GatewayState {
|
|
29
33
|
api: Bitrix24Api;
|
|
30
34
|
sendService: SendService;
|
|
35
|
+
mediaService: MediaService;
|
|
31
36
|
inboundHandler: InboundHandler;
|
|
32
37
|
}
|
|
33
38
|
|
|
34
39
|
let gatewayState: GatewayState | null = null;
|
|
35
40
|
|
|
41
|
+
// ─── Default command keyboard ────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
/** Default keyboard shown with command responses and welcome messages. */
|
|
44
|
+
export const DEFAULT_COMMAND_KEYBOARD: B24Keyboard = [
|
|
45
|
+
{ TEXT: 'Help', COMMAND: 'help', BG_COLOR_TOKEN: 'primary', DISPLAY: 'LINE' },
|
|
46
|
+
{ TEXT: 'Status', COMMAND: 'status', DISPLAY: 'LINE' },
|
|
47
|
+
{ TEXT: 'Commands', COMMAND: 'commands', DISPLAY: 'LINE' },
|
|
48
|
+
{ TYPE: 'NEWLINE' },
|
|
49
|
+
{ TEXT: 'New session', COMMAND: 'new', DISPLAY: 'LINE' },
|
|
50
|
+
{ TEXT: 'Models', COMMAND: 'models', DISPLAY: 'LINE' },
|
|
51
|
+
];
|
|
52
|
+
|
|
36
53
|
// ─── Keyboard / Button conversion ────────────────────────────────────────────
|
|
37
54
|
|
|
38
|
-
|
|
55
|
+
/** Generic button format used by OpenClaw channelData. */
|
|
56
|
+
export interface ChannelButton {
|
|
39
57
|
text: string;
|
|
40
58
|
callback_data?: string;
|
|
41
59
|
style?: string;
|
|
42
60
|
}
|
|
43
61
|
|
|
44
62
|
/**
|
|
45
|
-
* Convert
|
|
46
|
-
*
|
|
47
|
-
*
|
|
63
|
+
* Convert OpenClaw button rows to B24 flat KEYBOARD array.
|
|
64
|
+
* Input: Array<Array<{ text, callback_data, style }>>
|
|
65
|
+
* Output: flat array with { TYPE: 'NEWLINE' } separators between rows.
|
|
48
66
|
*/
|
|
49
|
-
function convertButtonsToKeyboard(rows:
|
|
67
|
+
export function convertButtonsToKeyboard(rows: ChannelButton[][]): B24Keyboard {
|
|
50
68
|
const keyboard: B24Keyboard = [];
|
|
51
69
|
|
|
52
70
|
for (let i = 0; i < rows.length; i++) {
|
|
@@ -86,9 +104,9 @@ function convertButtonsToKeyboard(rows: TelegramButton[][]): B24Keyboard {
|
|
|
86
104
|
|
|
87
105
|
/**
|
|
88
106
|
* Extract B24 keyboard from a dispatcher payload's channelData.
|
|
89
|
-
* Checks bitrix24-specific data first, then falls back to
|
|
107
|
+
* Checks bitrix24-specific data first, then falls back to OpenClaw generic button format.
|
|
90
108
|
*/
|
|
91
|
-
function extractKeyboardFromPayload(
|
|
109
|
+
export function extractKeyboardFromPayload(
|
|
92
110
|
payload: { channelData?: Record<string, unknown> },
|
|
93
111
|
): B24Keyboard | undefined {
|
|
94
112
|
const cd = payload.channelData;
|
|
@@ -100,8 +118,8 @@ function extractKeyboardFromPayload(
|
|
|
100
118
|
return b24Data.keyboard;
|
|
101
119
|
}
|
|
102
120
|
|
|
103
|
-
// Translate from
|
|
104
|
-
const tgData = cd.telegram as { buttons?:
|
|
121
|
+
// Translate from OpenClaw generic button format (channelData.telegram key)
|
|
122
|
+
const tgData = cd.telegram as { buttons?: ChannelButton[][] } | undefined;
|
|
105
123
|
if (tgData?.buttons?.length) {
|
|
106
124
|
return convertButtonsToKeyboard(tgData.buttons);
|
|
107
125
|
}
|
|
@@ -270,7 +288,7 @@ export const bitrix24Plugin = {
|
|
|
270
288
|
|
|
271
289
|
capabilities: {
|
|
272
290
|
chatTypes: ['direct', 'group'] as const,
|
|
273
|
-
media:
|
|
291
|
+
media: true,
|
|
274
292
|
reactions: false,
|
|
275
293
|
threads: false,
|
|
276
294
|
nativeCommands: true,
|
|
@@ -283,12 +301,33 @@ export const bitrix24Plugin = {
|
|
|
283
301
|
},
|
|
284
302
|
|
|
285
303
|
security: {
|
|
286
|
-
resolveDmPolicy: (
|
|
287
|
-
account
|
|
304
|
+
resolveDmPolicy: (params: { cfg?: Record<string, unknown>; accountId?: string; account?: { config?: Record<string, unknown> } }) => ({
|
|
305
|
+
policy: (params.account?.config?.dmPolicy as string) ?? 'pairing',
|
|
306
|
+
allowFrom: (params.account?.config?.allowFrom as string[]) ?? [],
|
|
307
|
+
policyPath: 'channels.bitrix24.dmPolicy',
|
|
308
|
+
allowFromPath: 'channels.bitrix24.',
|
|
309
|
+
approveHint: 'openclaw pairing approve bitrix24 <CODE>',
|
|
310
|
+
normalizeEntry: (raw: string) => raw.replace(/^(bitrix24|b24|bx24):/i, ''),
|
|
311
|
+
}),
|
|
288
312
|
normalizeAllowFrom: (entry: string) =>
|
|
289
|
-
entry.replace(/^(bitrix24|b24|bx24)
|
|
313
|
+
entry.replace(/^(bitrix24|b24|bx24):/i, ''),
|
|
290
314
|
},
|
|
291
315
|
|
|
316
|
+
pairing: {
|
|
317
|
+
idLabel: 'bitrix24UserId',
|
|
318
|
+
normalizeAllowEntry: (entry: string) => normalizeAllowEntry(entry),
|
|
319
|
+
notifyApproval: async (params: { cfg: Record<string, unknown>; id: string; runtime?: unknown }) => {
|
|
320
|
+
const { config: acctCfg } = resolveAccount(params.cfg);
|
|
321
|
+
if (!acctCfg.webhookUrl) return;
|
|
322
|
+
const api = new Bitrix24Api();
|
|
323
|
+
try {
|
|
324
|
+
await api.sendMessage(acctCfg.webhookUrl, params.id, '\u2705 OpenClaw access approved.');
|
|
325
|
+
} finally {
|
|
326
|
+
api.destroy();
|
|
327
|
+
}
|
|
328
|
+
},
|
|
329
|
+
} satisfies ChannelPairingAdapter,
|
|
330
|
+
|
|
292
331
|
outbound: {
|
|
293
332
|
deliveryMode: 'direct' as const,
|
|
294
333
|
|
|
@@ -409,6 +448,7 @@ export const bitrix24Plugin = {
|
|
|
409
448
|
const clientId = createHash('md5').update(config.webhookUrl).digest('hex');
|
|
410
449
|
const api = new Bitrix24Api({ logger, clientId });
|
|
411
450
|
const sendService = new SendService(api, logger);
|
|
451
|
+
const mediaService = new MediaService(api, logger);
|
|
412
452
|
|
|
413
453
|
// Register or update bot on the B24 portal
|
|
414
454
|
const botId = await ensureBotRegistered(api, config, logger);
|
|
@@ -435,6 +475,65 @@ export const bitrix24Plugin = {
|
|
|
435
475
|
const runtime = getBitrix24Runtime();
|
|
436
476
|
const cfg = runtime.config.loadConfig();
|
|
437
477
|
|
|
478
|
+
// Pairing-aware access control
|
|
479
|
+
const accessResult = await checkAccessWithPairing({
|
|
480
|
+
senderId: msgCtx.senderId,
|
|
481
|
+
config,
|
|
482
|
+
runtime,
|
|
483
|
+
accountId: ctx.accountId,
|
|
484
|
+
pairingAdapter: bitrix24Plugin.pairing,
|
|
485
|
+
sendReply: async (text: string) => {
|
|
486
|
+
const replySendCtx = {
|
|
487
|
+
webhookUrl: config.webhookUrl,
|
|
488
|
+
clientEndpoint: msgCtx.clientEndpoint,
|
|
489
|
+
botToken: msgCtx.botToken,
|
|
490
|
+
dialogId: msgCtx.chatId,
|
|
491
|
+
};
|
|
492
|
+
await sendService.sendText(replySendCtx, text, { convertMarkdown: false });
|
|
493
|
+
},
|
|
494
|
+
logger,
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
if (accessResult !== 'allow') {
|
|
498
|
+
logger.debug(`Message blocked (${accessResult})`, { senderId: msgCtx.senderId });
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// Download media files if present
|
|
503
|
+
let mediaFields: Record<string, unknown> = {};
|
|
504
|
+
if (msgCtx.media.length > 0) {
|
|
505
|
+
const downloaded = (await Promise.all(
|
|
506
|
+
msgCtx.media.map((m) =>
|
|
507
|
+
mediaService.downloadMedia({
|
|
508
|
+
fileId: m.id,
|
|
509
|
+
fileName: m.name,
|
|
510
|
+
extension: m.extension,
|
|
511
|
+
clientEndpoint: msgCtx.clientEndpoint,
|
|
512
|
+
userToken: msgCtx.userToken,
|
|
513
|
+
webhookUrl: config.webhookUrl,
|
|
514
|
+
}),
|
|
515
|
+
),
|
|
516
|
+
)).filter(Boolean) as DownloadedMedia[];
|
|
517
|
+
|
|
518
|
+
if (downloaded.length > 0) {
|
|
519
|
+
mediaFields = {
|
|
520
|
+
MediaPath: downloaded[0].path,
|
|
521
|
+
MediaType: downloaded[0].contentType,
|
|
522
|
+
MediaUrl: downloaded[0].path,
|
|
523
|
+
MediaPaths: downloaded.map((m) => m.path),
|
|
524
|
+
MediaUrls: downloaded.map((m) => m.path),
|
|
525
|
+
MediaTypes: downloaded.map((m) => m.contentType),
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// Use placeholder body for media-only messages
|
|
531
|
+
let body = msgCtx.text;
|
|
532
|
+
if (!body && msgCtx.media.length > 0) {
|
|
533
|
+
const hasImage = msgCtx.media.some((m) => m.type === 'image');
|
|
534
|
+
body = hasImage ? '<media:image>' : '<media:document>';
|
|
535
|
+
}
|
|
536
|
+
|
|
438
537
|
// Resolve which agent handles this conversation
|
|
439
538
|
const route = runtime.channel.routing.resolveAgentRoute({
|
|
440
539
|
cfg,
|
|
@@ -454,9 +553,9 @@ export const bitrix24Plugin = {
|
|
|
454
553
|
|
|
455
554
|
// Build and finalize inbound context for OpenClaw agent
|
|
456
555
|
const inboundCtx = runtime.channel.reply.finalizeInboundContext({
|
|
457
|
-
Body:
|
|
458
|
-
BodyForAgent:
|
|
459
|
-
RawBody:
|
|
556
|
+
Body: body,
|
|
557
|
+
BodyForAgent: body,
|
|
558
|
+
RawBody: body,
|
|
460
559
|
From: `bitrix24:${msgCtx.chatId}`,
|
|
461
560
|
To: `bitrix24:${msgCtx.chatId}`,
|
|
462
561
|
SessionKey: route.sessionKey,
|
|
@@ -473,6 +572,7 @@ export const bitrix24Plugin = {
|
|
|
473
572
|
CommandAuthorized: true,
|
|
474
573
|
OriginatingChannel: 'bitrix24',
|
|
475
574
|
OriginatingTo: `bitrix24:${msgCtx.chatId}`,
|
|
575
|
+
...mediaFields,
|
|
476
576
|
});
|
|
477
577
|
|
|
478
578
|
const sendCtx = {
|
|
@@ -489,6 +589,18 @@ export const bitrix24Plugin = {
|
|
|
489
589
|
cfg,
|
|
490
590
|
dispatcherOptions: {
|
|
491
591
|
deliver: async (payload) => {
|
|
592
|
+
// Send media if present in reply
|
|
593
|
+
const mediaUrls = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
|
|
594
|
+
for (const mediaUrl of mediaUrls) {
|
|
595
|
+
await mediaService.uploadMediaToChat({
|
|
596
|
+
localPath: mediaUrl,
|
|
597
|
+
fileName: basename(mediaUrl),
|
|
598
|
+
chatId: Number(msgCtx.chatInternalId),
|
|
599
|
+
clientEndpoint: msgCtx.clientEndpoint,
|
|
600
|
+
botToken: msgCtx.botToken,
|
|
601
|
+
});
|
|
602
|
+
}
|
|
603
|
+
// Send text if present
|
|
492
604
|
if (payload.text) {
|
|
493
605
|
const keyboard = extractKeyboardFromPayload(payload);
|
|
494
606
|
await sendService.sendText(sendCtx, payload.text, keyboard ? { keyboard } : undefined);
|
|
@@ -529,14 +641,39 @@ export const bitrix24Plugin = {
|
|
|
529
641
|
|
|
530
642
|
logger.info('Inbound command', { commandName, commandParams, senderId, dialogId });
|
|
531
643
|
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
644
|
+
let runtime;
|
|
645
|
+
let cfg;
|
|
646
|
+
try {
|
|
647
|
+
runtime = getBitrix24Runtime();
|
|
648
|
+
cfg = runtime.config.loadConfig();
|
|
649
|
+
} catch (err) {
|
|
650
|
+
logger.error('Failed to get runtime/config for command', err);
|
|
535
651
|
return;
|
|
536
652
|
}
|
|
537
653
|
|
|
538
|
-
|
|
539
|
-
|
|
654
|
+
// Pairing-aware access control (commands don't send pairing replies)
|
|
655
|
+
let accessResult;
|
|
656
|
+
try {
|
|
657
|
+
accessResult = await checkAccessWithPairing({
|
|
658
|
+
senderId,
|
|
659
|
+
config,
|
|
660
|
+
runtime,
|
|
661
|
+
accountId: ctx.accountId,
|
|
662
|
+
pairingAdapter: bitrix24Plugin.pairing,
|
|
663
|
+
sendReply: async () => {},
|
|
664
|
+
logger,
|
|
665
|
+
});
|
|
666
|
+
} catch (err) {
|
|
667
|
+
logger.error('Access check failed for command', err);
|
|
668
|
+
return;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
if (accessResult !== 'allow') {
|
|
672
|
+
logger.debug(`Command blocked (${accessResult})`, { senderId });
|
|
673
|
+
return;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
logger.debug('Command access allowed, resolving route', { commandText });
|
|
540
677
|
|
|
541
678
|
const route = runtime.channel.routing.resolveAgentRoute({
|
|
542
679
|
cfg,
|
|
@@ -545,8 +682,11 @@ export const bitrix24Plugin = {
|
|
|
545
682
|
peer: { kind: isDm ? 'direct' : 'group', id: dialogId },
|
|
546
683
|
});
|
|
547
684
|
|
|
548
|
-
|
|
549
|
-
|
|
685
|
+
logger.debug('Command route resolved', { sessionKey: route.sessionKey });
|
|
686
|
+
|
|
687
|
+
// Each command invocation gets a unique ephemeral session
|
|
688
|
+
// so the gateway doesn't treat it as "already handled".
|
|
689
|
+
const slashSessionKey = `bitrix24:slash:${senderId}:${Date.now()}`;
|
|
550
690
|
|
|
551
691
|
const inboundCtx = runtime.channel.reply.finalizeInboundContext({
|
|
552
692
|
Body: commandText,
|
|
@@ -580,15 +720,23 @@ export const bitrix24Plugin = {
|
|
|
580
720
|
dialogId,
|
|
581
721
|
};
|
|
582
722
|
|
|
723
|
+
logger.debug('Dispatching command to agent', { commandText, slashSessionKey });
|
|
724
|
+
|
|
583
725
|
try {
|
|
584
726
|
await runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
585
727
|
ctx: inboundCtx,
|
|
586
728
|
cfg,
|
|
587
729
|
dispatcherOptions: {
|
|
588
730
|
deliver: async (payload) => {
|
|
731
|
+
logger.debug('Command deliver callback', {
|
|
732
|
+
hasText: !!payload.text,
|
|
733
|
+
textLen: payload.text?.length ?? 0,
|
|
734
|
+
hasMedia: !!(payload.mediaUrl || payload.mediaUrls?.length),
|
|
735
|
+
});
|
|
589
736
|
if (payload.text) {
|
|
590
|
-
|
|
591
|
-
|
|
737
|
+
// Use agent-provided keyboard if any, otherwise re-attach default command keyboard
|
|
738
|
+
const keyboard = extractKeyboardFromPayload(payload) ?? DEFAULT_COMMAND_KEYBOARD;
|
|
739
|
+
await sendService.sendText(sendCtx, payload.text, { keyboard });
|
|
592
740
|
}
|
|
593
741
|
},
|
|
594
742
|
onReplyStart: async () => {
|
|
@@ -601,6 +749,7 @@ export const bitrix24Plugin = {
|
|
|
601
749
|
},
|
|
602
750
|
},
|
|
603
751
|
});
|
|
752
|
+
logger.debug('Command dispatch completed', { commandText });
|
|
604
753
|
} catch (err) {
|
|
605
754
|
logger.error('Error dispatching command to agent', err);
|
|
606
755
|
}
|
|
@@ -619,7 +768,8 @@ export const bitrix24Plugin = {
|
|
|
619
768
|
botEntry.client_endpoint,
|
|
620
769
|
botEntry.access_token,
|
|
621
770
|
dialogId,
|
|
622
|
-
`${config.botName ?? 'OpenClaw'} ready. Send me a message
|
|
771
|
+
`${config.botName ?? 'OpenClaw'} ready. Send me a message or pick a command below.`,
|
|
772
|
+
{ KEYBOARD: DEFAULT_COMMAND_KEYBOARD },
|
|
623
773
|
);
|
|
624
774
|
} catch (err) {
|
|
625
775
|
logger.error('Failed to send welcome message', err);
|
|
@@ -628,7 +778,7 @@ export const bitrix24Plugin = {
|
|
|
628
778
|
},
|
|
629
779
|
});
|
|
630
780
|
|
|
631
|
-
gatewayState = { api, sendService, inboundHandler };
|
|
781
|
+
gatewayState = { api, sendService, mediaService, inboundHandler };
|
|
632
782
|
|
|
633
783
|
logger.info(`[${ctx.accountId}] Bitrix24 channel started`);
|
|
634
784
|
|
package/src/commands.ts
CHANGED
package/src/config-schema.ts
CHANGED
|
@@ -7,7 +7,7 @@ const AccountSchema = z.object({
|
|
|
7
7
|
botCode: z.string().optional().default('openclaw'),
|
|
8
8
|
botAvatar: z.string().optional(),
|
|
9
9
|
callbackUrl: z.string().url().optional(),
|
|
10
|
-
dmPolicy: z.enum(['open', 'allowlist', 'pairing']).optional().default('
|
|
10
|
+
dmPolicy: z.enum(['open', 'allowlist', 'pairing']).optional().default('pairing'),
|
|
11
11
|
allowFrom: z.array(z.string()).optional(),
|
|
12
12
|
showTyping: z.boolean().optional().default(true),
|
|
13
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
|
}
|