@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 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: unknown;
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 as { logger: { info: (...args: unknown[]) => void; warn: (...args: unknown[]) => void; error: (...args: unknown[]) => void; debug: (...args: unknown[]) => void }; [key: string]: unknown });
27
+ setBitrix24Runtime(api.runtime);
28
28
 
29
29
  api.registerChannel({ plugin: bitrix24Plugin });
30
30
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ihazz/bitrix24",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "Bitrix24 Messenger channel for OpenClaw",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
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(params ?? {}),
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 type { B24MsgContext, B24JoinChatEvent } from './types.js';
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
- const api = new Bitrix24Api({ logger });
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,
@@ -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
- logger: {
3
- info: (...args: unknown[]) => void;
4
- warn: (...args: unknown[]) => void;
5
- error: (...args: unknown[]) => void;
6
- debug: (...args: unknown[]) => void;
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
@@ -243,6 +243,7 @@ export interface Bitrix24AccountConfig {
243
243
  botCode?: string;
244
244
  botAvatar?: string;
245
245
  callbackPath?: string;
246
+ callbackUrl?: string;
246
247
  dmPolicy?: 'open' | 'allowlist' | 'pairing';
247
248
  allowFrom?: string[];
248
249
  showTyping?: boolean;