@ihazz/bitrix24 0.1.3 → 0.1.4
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 +2 -0
- package/index.ts +3 -3
- package/package.json +1 -1
- package/src/api.ts +32 -2
- package/src/channel.ts +152 -2
- package/src/config-schema.ts +1 -0
- package/src/runtime.ts +61 -5
- package/src/types.ts +1 -0
package/README.md
CHANGED
|
@@ -48,6 +48,7 @@ Add to your `openclaw.json`:
|
|
|
48
48
|
"botName": "OpenClaw",
|
|
49
49
|
"botCode": "openclaw",
|
|
50
50
|
"callbackPath": "/hooks/bitrix24",
|
|
51
|
+
"callbackUrl": "https://your-server.com/hooks/bitrix24",
|
|
51
52
|
"dmPolicy": "open",
|
|
52
53
|
"showTyping": true
|
|
53
54
|
}
|
|
@@ -65,6 +66,7 @@ Only `webhookUrl` is required. The gateway will not start without it.
|
|
|
65
66
|
| `botName` | `"OpenClaw"` | Bot display name (shown in welcome message) |
|
|
66
67
|
| `botCode` | `"openclaw"` | Unique bot code for `imbot.register` |
|
|
67
68
|
| `callbackPath` | `"/hooks/bitrix24"` | Webhook endpoint path for incoming B24 events |
|
|
69
|
+
| `callbackUrl` | — | Full public URL for bot registration (e.g. `https://your-server.com/hooks/bitrix24`).|
|
|
68
70
|
| `dmPolicy` | `"open"` | Access policy: `"open"` / `"allowlist"` / `"pairing"` |
|
|
69
71
|
| `allowFrom` | — | Allowed B24 user IDs (when `dmPolicy: "allowlist"`) |
|
|
70
72
|
| `showTyping` | `true` | Send typing indicator before responding |
|
package/index.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { bitrix24Plugin, handleWebhookRequest } from './src/channel.js';
|
|
2
|
-
import { setBitrix24Runtime } from './src/runtime.js';
|
|
2
|
+
import { setBitrix24Runtime, type PluginRuntime } from './src/runtime.js';
|
|
3
3
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
4
4
|
|
|
5
5
|
interface OpenClawPluginApi {
|
|
6
6
|
config: Record<string, unknown>;
|
|
7
|
-
runtime:
|
|
7
|
+
runtime: PluginRuntime;
|
|
8
8
|
registerChannel: (opts: { plugin: typeof bitrix24Plugin }) => void;
|
|
9
9
|
registerHttpRoute: (params: {
|
|
10
10
|
path: string;
|
|
@@ -24,7 +24,7 @@ export default {
|
|
|
24
24
|
description: 'Bitrix24 Messenger channel for OpenClaw',
|
|
25
25
|
|
|
26
26
|
register(api: OpenClawPluginApi) {
|
|
27
|
-
setBitrix24Runtime(api.runtime
|
|
27
|
+
setBitrix24Runtime(api.runtime);
|
|
28
28
|
|
|
29
29
|
api.registerChannel({ plugin: bitrix24Plugin });
|
|
30
30
|
|
package/package.json
CHANGED
package/src/api.ts
CHANGED
|
@@ -12,10 +12,12 @@ interface Logger {
|
|
|
12
12
|
export class Bitrix24Api {
|
|
13
13
|
private rateLimiter: RateLimiter;
|
|
14
14
|
private logger: Logger;
|
|
15
|
+
private clientId?: string;
|
|
15
16
|
|
|
16
|
-
constructor(opts: { maxPerSecond?: number; logger?: Logger } = {}) {
|
|
17
|
+
constructor(opts: { maxPerSecond?: number; logger?: Logger; clientId?: string } = {}) {
|
|
17
18
|
this.rateLimiter = new RateLimiter({ maxPerSecond: opts.maxPerSecond ?? 2 });
|
|
18
19
|
this.logger = opts.logger ?? defaultLogger;
|
|
20
|
+
this.clientId = opts.clientId;
|
|
19
21
|
}
|
|
20
22
|
|
|
21
23
|
/**
|
|
@@ -33,12 +35,18 @@ export class Bitrix24Api {
|
|
|
33
35
|
const url = `${webhookUrl.replace(/\/+$/, '')}/${method}.json`;
|
|
34
36
|
this.logger.debug(`API call: ${method}`, { url });
|
|
35
37
|
|
|
38
|
+
// In webhook mode, CLIENT_ID identifies the "app" that owns the bot
|
|
39
|
+
const payload = { ...(params ?? {}) };
|
|
40
|
+
if (this.clientId && !payload.CLIENT_ID) {
|
|
41
|
+
payload.CLIENT_ID = this.clientId;
|
|
42
|
+
}
|
|
43
|
+
|
|
36
44
|
const result = await withRetry(
|
|
37
45
|
async () => {
|
|
38
46
|
const response = await fetch(url, {
|
|
39
47
|
method: 'POST',
|
|
40
48
|
headers: { 'Content-Type': 'application/json' },
|
|
41
|
-
body: JSON.stringify(
|
|
49
|
+
body: JSON.stringify(payload),
|
|
42
50
|
});
|
|
43
51
|
|
|
44
52
|
if (!response.ok) {
|
|
@@ -215,6 +223,16 @@ export class Bitrix24Api {
|
|
|
215
223
|
return result.result;
|
|
216
224
|
}
|
|
217
225
|
|
|
226
|
+
async listBots(
|
|
227
|
+
webhookUrl: string,
|
|
228
|
+
): Promise<Array<{ ID: number; NAME: string; CODE: string; OPENLINE: string }>> {
|
|
229
|
+
// B24 returns an object keyed by bot ID, not an array
|
|
230
|
+
const result = await this.callWebhook<
|
|
231
|
+
Record<string, { ID: number; NAME: string; CODE: string; OPENLINE: string }>
|
|
232
|
+
>(webhookUrl, 'imbot.bot.list', {});
|
|
233
|
+
return Object.values(result.result ?? {});
|
|
234
|
+
}
|
|
235
|
+
|
|
218
236
|
async registerBot(
|
|
219
237
|
webhookUrl: string,
|
|
220
238
|
params: Record<string, unknown>,
|
|
@@ -223,6 +241,18 @@ export class Bitrix24Api {
|
|
|
223
241
|
return result.result;
|
|
224
242
|
}
|
|
225
243
|
|
|
244
|
+
async updateBot(
|
|
245
|
+
webhookUrl: string,
|
|
246
|
+
botId: number,
|
|
247
|
+
fields: Record<string, unknown>,
|
|
248
|
+
): Promise<boolean> {
|
|
249
|
+
const result = await this.callWebhook<boolean>(webhookUrl, 'imbot.update', {
|
|
250
|
+
BOT_ID: botId,
|
|
251
|
+
FIELDS: fields,
|
|
252
|
+
});
|
|
253
|
+
return result.result;
|
|
254
|
+
}
|
|
255
|
+
|
|
226
256
|
async unregisterBot(webhookUrl: string, botId: number): Promise<boolean> {
|
|
227
257
|
const result = await this.callWebhook<boolean>(webhookUrl, 'imbot.unregister', {
|
|
228
258
|
BOT_ID: botId,
|
package/src/channel.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
1
2
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
3
|
import { listAccountIds, resolveAccount, getConfig } from './config.js';
|
|
3
4
|
import { Bitrix24Api } from './api.js';
|
|
4
5
|
import { SendService } from './send-service.js';
|
|
5
6
|
import { InboundHandler } from './inbound-handler.js';
|
|
6
7
|
import { defaultLogger } from './utils.js';
|
|
7
|
-
import
|
|
8
|
+
import { getBitrix24Runtime } from './runtime.js';
|
|
9
|
+
import type { B24MsgContext, B24JoinChatEvent, Bitrix24AccountConfig } from './types.js';
|
|
8
10
|
|
|
9
11
|
interface Logger {
|
|
10
12
|
info: (...args: unknown[]) => void;
|
|
@@ -22,6 +24,64 @@ interface GatewayState {
|
|
|
22
24
|
|
|
23
25
|
let gatewayState: GatewayState | null = null;
|
|
24
26
|
|
|
27
|
+
/**
|
|
28
|
+
* Register or update the bot on the Bitrix24 portal.
|
|
29
|
+
* Checks imbot.bot.list first; if a bot with the same CODE exists, updates it.
|
|
30
|
+
* Otherwise registers a new one.
|
|
31
|
+
*/
|
|
32
|
+
async function ensureBotRegistered(
|
|
33
|
+
api: Bitrix24Api,
|
|
34
|
+
config: Bitrix24AccountConfig,
|
|
35
|
+
logger: Logger,
|
|
36
|
+
): Promise<number | null> {
|
|
37
|
+
const { webhookUrl, callbackUrl, botCode, botName } = config;
|
|
38
|
+
if (!webhookUrl || !callbackUrl) {
|
|
39
|
+
if (!callbackUrl) {
|
|
40
|
+
logger.warn('callbackUrl not configured — skipping bot registration (bot must be registered manually)');
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const code = botCode ?? 'openclaw';
|
|
46
|
+
const name = botName ?? 'OpenClaw';
|
|
47
|
+
|
|
48
|
+
// Check if bot already exists
|
|
49
|
+
try {
|
|
50
|
+
const bots = await api.listBots(webhookUrl);
|
|
51
|
+
const existing = bots.find((b) => b.CODE === code);
|
|
52
|
+
|
|
53
|
+
if (existing) {
|
|
54
|
+
logger.info(`Bot "${code}" already registered (ID=${existing.ID}), updating EVENT_HANDLER`);
|
|
55
|
+
await api.updateBot(webhookUrl, existing.ID, {
|
|
56
|
+
EVENT_HANDLER: callbackUrl,
|
|
57
|
+
PROPERTIES: { NAME: name },
|
|
58
|
+
});
|
|
59
|
+
return existing.ID;
|
|
60
|
+
}
|
|
61
|
+
} catch (err) {
|
|
62
|
+
logger.warn('Failed to list existing bots, will try to register', err);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Register new bot
|
|
66
|
+
try {
|
|
67
|
+
const botId = await api.registerBot(webhookUrl, {
|
|
68
|
+
CODE: code,
|
|
69
|
+
TYPE: 'B',
|
|
70
|
+
EVENT_HANDLER: callbackUrl,
|
|
71
|
+
PROPERTIES: {
|
|
72
|
+
NAME: name,
|
|
73
|
+
WORK_POSITION: 'AI Assistant',
|
|
74
|
+
COLOR: 'AZURE',
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
logger.info(`Bot "${code}" registered (ID=${botId})`);
|
|
78
|
+
return botId;
|
|
79
|
+
} catch (err) {
|
|
80
|
+
logger.error('Failed to register bot', err);
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
25
85
|
/**
|
|
26
86
|
* Handle an incoming HTTP request on the webhook route.
|
|
27
87
|
* Called by the HTTP route registered in index.ts.
|
|
@@ -166,12 +226,102 @@ export const bitrix24Plugin = {
|
|
|
166
226
|
|
|
167
227
|
logger.info(`[${ctx.accountId}] starting Bitrix24 channel`);
|
|
168
228
|
|
|
169
|
-
|
|
229
|
+
// Derive CLIENT_ID from webhookUrl (md5) — stable and unique per portal
|
|
230
|
+
const clientId = createHash('md5').update(config.webhookUrl).digest('hex');
|
|
231
|
+
const api = new Bitrix24Api({ logger, clientId });
|
|
170
232
|
const sendService = new SendService(api, logger);
|
|
171
233
|
|
|
234
|
+
// Register or update bot on the B24 portal
|
|
235
|
+
await ensureBotRegistered(api, config, logger);
|
|
236
|
+
|
|
172
237
|
const inboundHandler = new InboundHandler({
|
|
173
238
|
config,
|
|
174
239
|
logger,
|
|
240
|
+
|
|
241
|
+
onMessage: async (msgCtx: B24MsgContext) => {
|
|
242
|
+
logger.info('Inbound message', {
|
|
243
|
+
senderId: msgCtx.senderId,
|
|
244
|
+
chatId: msgCtx.chatId,
|
|
245
|
+
messageId: msgCtx.messageId,
|
|
246
|
+
textLen: msgCtx.text.length,
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
const runtime = getBitrix24Runtime();
|
|
250
|
+
const cfg = runtime.config.loadConfig();
|
|
251
|
+
|
|
252
|
+
// Resolve which agent handles this conversation
|
|
253
|
+
const route = runtime.channel.routing.resolveAgentRoute({
|
|
254
|
+
cfg,
|
|
255
|
+
channel: 'bitrix24',
|
|
256
|
+
accountId: ctx.accountId,
|
|
257
|
+
peer: {
|
|
258
|
+
kind: msgCtx.isDm ? 'direct' : 'group',
|
|
259
|
+
id: msgCtx.chatId,
|
|
260
|
+
},
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
logger.debug('Resolved route', {
|
|
264
|
+
sessionKey: route.sessionKey,
|
|
265
|
+
agentId: route.agentId,
|
|
266
|
+
matchedBy: route.matchedBy,
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
// Build and finalize inbound context for OpenClaw agent
|
|
270
|
+
const inboundCtx = runtime.channel.reply.finalizeInboundContext({
|
|
271
|
+
Body: msgCtx.text,
|
|
272
|
+
BodyForAgent: msgCtx.text,
|
|
273
|
+
RawBody: msgCtx.text,
|
|
274
|
+
From: `bitrix24:${msgCtx.chatId}`,
|
|
275
|
+
To: `bitrix24:${msgCtx.chatId}`,
|
|
276
|
+
SessionKey: route.sessionKey,
|
|
277
|
+
AccountId: route.accountId,
|
|
278
|
+
ChatType: msgCtx.isDm ? 'direct' : 'group',
|
|
279
|
+
ConversationLabel: msgCtx.senderName,
|
|
280
|
+
SenderName: msgCtx.senderName,
|
|
281
|
+
SenderId: msgCtx.senderId,
|
|
282
|
+
Provider: 'bitrix24',
|
|
283
|
+
Surface: 'bitrix24',
|
|
284
|
+
MessageSid: msgCtx.messageId,
|
|
285
|
+
Timestamp: Date.now(),
|
|
286
|
+
WasMentioned: false,
|
|
287
|
+
CommandAuthorized: false,
|
|
288
|
+
OriginatingChannel: 'bitrix24',
|
|
289
|
+
OriginatingTo: `bitrix24:${msgCtx.chatId}`,
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
const sendCtx = {
|
|
293
|
+
webhookUrl: config.webhookUrl,
|
|
294
|
+
clientEndpoint: msgCtx.clientEndpoint,
|
|
295
|
+
botToken: msgCtx.botToken,
|
|
296
|
+
dialogId: msgCtx.chatId,
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
// Dispatch to AI agent; deliver callback sends reply back to B24
|
|
300
|
+
try {
|
|
301
|
+
await runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
302
|
+
ctx: inboundCtx,
|
|
303
|
+
cfg,
|
|
304
|
+
dispatcherOptions: {
|
|
305
|
+
deliver: async (payload) => {
|
|
306
|
+
if (payload.text) {
|
|
307
|
+
await sendService.sendText(sendCtx, payload.text);
|
|
308
|
+
}
|
|
309
|
+
},
|
|
310
|
+
onReplyStart: async () => {
|
|
311
|
+
if (config.showTyping !== false) {
|
|
312
|
+
await sendService.sendTyping(sendCtx);
|
|
313
|
+
}
|
|
314
|
+
},
|
|
315
|
+
onError: (err) => {
|
|
316
|
+
logger.error('Error delivering reply to B24', err);
|
|
317
|
+
},
|
|
318
|
+
},
|
|
319
|
+
});
|
|
320
|
+
} catch (err) {
|
|
321
|
+
logger.error('Error dispatching message to agent', err);
|
|
322
|
+
}
|
|
323
|
+
},
|
|
324
|
+
|
|
175
325
|
onJoinChat: async (event: B24JoinChatEvent) => {
|
|
176
326
|
logger.info('Bot joined chat', {
|
|
177
327
|
dialogId: event.data.PARAMS.DIALOG_ID,
|
package/src/config-schema.ts
CHANGED
|
@@ -7,6 +7,7 @@ const AccountSchema = z.object({
|
|
|
7
7
|
botCode: z.string().optional().default('openclaw'),
|
|
8
8
|
botAvatar: z.string().optional(),
|
|
9
9
|
callbackPath: z.string().optional().default('/hooks/bitrix24'),
|
|
10
|
+
callbackUrl: z.string().url().optional(),
|
|
10
11
|
dmPolicy: z.enum(['open', 'allowlist', 'pairing']).optional().default('open'),
|
|
11
12
|
allowFrom: z.array(z.string()).optional(),
|
|
12
13
|
showTyping: z.boolean().optional().default(true),
|
package/src/runtime.ts
CHANGED
|
@@ -1,9 +1,65 @@
|
|
|
1
|
+
interface ReplyPayload {
|
|
2
|
+
text?: string;
|
|
3
|
+
mediaUrl?: string;
|
|
4
|
+
mediaUrls?: string[];
|
|
5
|
+
isError?: boolean;
|
|
6
|
+
channelData?: Record<string, unknown>;
|
|
7
|
+
}
|
|
8
|
+
|
|
1
9
|
export interface PluginRuntime {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
10
|
+
config: {
|
|
11
|
+
loadConfig: () => Record<string, unknown>;
|
|
12
|
+
[key: string]: unknown;
|
|
13
|
+
};
|
|
14
|
+
channel: {
|
|
15
|
+
routing: {
|
|
16
|
+
resolveAgentRoute: (input: {
|
|
17
|
+
cfg: Record<string, unknown>;
|
|
18
|
+
channel: string;
|
|
19
|
+
accountId?: string | null;
|
|
20
|
+
peer?: { kind: string; id: string } | null;
|
|
21
|
+
}) => {
|
|
22
|
+
agentId: string;
|
|
23
|
+
channel: string;
|
|
24
|
+
accountId: string;
|
|
25
|
+
sessionKey: string;
|
|
26
|
+
mainSessionKey: string;
|
|
27
|
+
matchedBy: string;
|
|
28
|
+
};
|
|
29
|
+
};
|
|
30
|
+
reply: {
|
|
31
|
+
finalizeInboundContext: <T extends Record<string, unknown>>(
|
|
32
|
+
ctx: T,
|
|
33
|
+
opts?: Record<string, boolean>,
|
|
34
|
+
) => T;
|
|
35
|
+
dispatchReplyWithBufferedBlockDispatcher: (params: {
|
|
36
|
+
ctx: Record<string, unknown>;
|
|
37
|
+
cfg: Record<string, unknown>;
|
|
38
|
+
dispatcherOptions: {
|
|
39
|
+
deliver: (payload: ReplyPayload, info: { kind: string }) => Promise<void>;
|
|
40
|
+
onReplyStart?: () => Promise<void> | void;
|
|
41
|
+
onIdle?: () => void;
|
|
42
|
+
onCleanup?: () => void;
|
|
43
|
+
onError?: (err: unknown, info: { kind: string }) => void;
|
|
44
|
+
};
|
|
45
|
+
replyOptions?: Record<string, unknown>;
|
|
46
|
+
}) => Promise<{ queuedFinal: boolean; counts: Record<string, number> }>;
|
|
47
|
+
[key: string]: unknown;
|
|
48
|
+
};
|
|
49
|
+
session: {
|
|
50
|
+
recordInboundSession: (params: Record<string, unknown>) => Promise<void>;
|
|
51
|
+
[key: string]: unknown;
|
|
52
|
+
};
|
|
53
|
+
[key: string]: unknown;
|
|
54
|
+
};
|
|
55
|
+
logging: {
|
|
56
|
+
getChildLogger: (bindings?: Record<string, unknown>) => {
|
|
57
|
+
info: (...args: unknown[]) => void;
|
|
58
|
+
warn: (...args: unknown[]) => void;
|
|
59
|
+
error: (...args: unknown[]) => void;
|
|
60
|
+
debug: (...args: unknown[]) => void;
|
|
61
|
+
};
|
|
62
|
+
[key: string]: unknown;
|
|
7
63
|
};
|
|
8
64
|
[key: string]: unknown;
|
|
9
65
|
}
|
package/src/types.ts
CHANGED