@ihazz/bitrix24 1.0.0 → 1.0.2

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
@@ -37,7 +37,7 @@ Create an inbound webhook in Bitrix24:
37
37
  2. Create a webhook for your OpenClaw bot
38
38
  3. Grant scopes:
39
39
  - `imbot` — the minimum scope required for full bot operation
40
- - `im` — additionally required only for `agentMode`, so the bot can read incoming messages addressed to the webhook owner
40
+ - `im` — reserved for future `agentMode` support; `agentMode` is not production-ready yet and will be available in future versions
41
41
  - you may also grant any extra scopes beyond this set if they are needed for your Bitrix24 scenarios
42
42
  4. Save the webhook and copy the URL
43
43
 
@@ -94,15 +94,17 @@ If `eventMode` is omitted, the plugin uses:
94
94
  | `eventMode` | auto | `fetch` or `webhook`. Auto-selects from `callbackUrl` when omitted. |
95
95
  | `botName` | `"OpenClaw"` | Bot display name. |
96
96
  | `botCode` | auto | Optional explicit bot code. If omitted, the plugin registers the bot as `openclaw_<webhookUserId>`, and if that code is occupied it tries `openclaw_<webhookUserId>_2`, `_3`, and so on. |
97
- | `botToken` | derived from `webhookUrl` | Optional explicit bot token for `imbot.v2.Bot.*`. If omitted, it is derived automatically from `webhookUrl`. |
97
+ | `botToken` | `md5(webhookUrl)` | Bot token for `imbot.v2` authentication (32-char hex string). If omitted, derived as `md5(webhookUrl)`. **Strongly recommended to set explicitly** if your `webhookUrl` may change (e.g. ngrok, dynamic DNS) — otherwise the plugin loses control of the previously registered bot. You can find the token in the Bitrix24 admin panel or compute it as `md5` of the original webhook URL. |
98
98
  | `botAvatar` | — | Optional base64 avatar override. |
99
99
  | `dmPolicy` | `"webhookUser"` | Access policy: `webhookUser` or `pairing`. |
100
100
  | `showTyping` | `true` | Sends typing indicator before response. |
101
101
  | `enabled` | `true` | Enables or disables the account. |
102
- | `agentMode` | `false` | Enables additional user-event integration. Requires `im` scope. |
102
+ | `agentMode` | `false` | Reserved for future user-event integration. Not working yet; planned for future versions. |
103
103
  | `pollingIntervalMs` | `3000` | Base poll interval for `fetch` mode. |
104
104
  | `pollingFastIntervalMs` | `100` | Fast follow-up poll interval when more events are pending. |
105
105
 
106
+ ### Agent-Driven Reactions
107
+
106
108
  To enable agent-driven reactions, add `reactions` to the channel capabilities in your OpenClaw config:
107
109
 
108
110
  ```json
@@ -118,6 +120,32 @@ To enable agent-driven reactions, add `reactions` to the channel capabilities in
118
120
 
119
121
  The plugin maps standard Unicode emoji (👍, 🔥, 👀, etc.) to Bitrix24 reaction codes automatically. B24 reaction codes (like `like`, `fire`, `eyes`) can also be used directly.
120
122
 
123
+ ### Inline Buttons
124
+
125
+ To enable agent-driven inline buttons, add `inlineButtons` to the channel capabilities in your OpenClaw config:
126
+
127
+ ```json
128
+ {
129
+ "channels": {
130
+ "bitrix24": {
131
+ "webhookUrl": "...",
132
+ "capabilities": ["inlineButtons"]
133
+ }
134
+ }
135
+ }
136
+ ```
137
+
138
+ The plugin supports two button input forms:
139
+
140
+ - native Bitrix24 keyboard payload via `channelData.bitrix24.keyboard`
141
+ - generic OpenClaw button rows via `channelData.telegram.buttons`, which the plugin converts to Bitrix24 `KEYBOARD`
142
+
143
+ For converted generic buttons:
144
+
145
+ - `callback_data` starting with `/` becomes a native Bitrix24 slash command button
146
+ - any other `callback_data` becomes `ACTION=PUT`
147
+ - `primary`, `attention`, and `danger` button styles are mapped to Bitrix24 color tokens
148
+
121
149
  ## Access Policies
122
150
 
123
151
  ### `webhookUser`
@@ -161,7 +189,7 @@ openclaw pairing approve bitrix24 <CODE>
161
189
  - Direct messages
162
190
  - Typing indicators
163
191
  - Text replies with BBCode conversion
164
- - Inline keyboard buttons
192
+ - Inline keyboard buttons (`channelData.bitrix24.keyboard` and converted generic button rows)
165
193
  - Reactions (agent can add/remove reactions on messages via `imbot.v2.Chat.Message.Reaction.*`)
166
194
  - File upload to chat via `imbot.v2.File.upload`
167
195
  - File download via `imbot.v2.File.download`
@@ -188,7 +216,7 @@ There is already account-config scaffolding in the plugin, but the current produ
188
216
 
189
217
  - Verify `webhookUrl` is valid and active.
190
218
  - If you use `eventMode: "webhook"`, verify `callbackUrl` is reachable from the internet over HTTPS.
191
- - If `agentMode` is enabled, verify the webhook also has `im` scope.
219
+ - Do not rely on `agentMode` yet. It is not working in the current release and is planned for future versions.
192
220
  - Do not expect the bot to work in group chats.
193
221
  - If file transfer fails, verify the file size and Bitrix24-side availability of the attachment.
194
222
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ihazz/bitrix24",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "description": "Bitrix24 Messenger channel for OpenClaw",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -66,5 +66,6 @@ When you see a message from a Bitrix24 user, you can react to acknowledge it bef
66
66
  ## Writing Style (Bitrix24)
67
67
 
68
68
  - Direct, professional, moderate length.
69
+ - **Always reply in the same language the user writes in.** If the user writes in Russian, reply in Russian. If in English, reply in English.
69
70
  - Bitrix24 uses BBCode for formatting (conversion is automatic).
70
71
  - Inline keyboard buttons are supported for interactive responses.
package/src/channel.ts CHANGED
@@ -552,8 +552,10 @@ export function convertButtonsToKeyboard(rows: ChannelButton[][]): B24Keyboard {
552
552
  b24Btn.COMMAND_PARAMS = parts.slice(1).join(' ');
553
553
  }
554
554
  } else if (btn.callback_data) {
555
- b24Btn.ACTION = 'PUT';
556
- b24Btn.ACTION_VALUE = btn.callback_data;
555
+ b24Btn.ACTION = 'SEND';
556
+ // Always use button text as ACTION_VALUE so the user sees readable text in chat,
557
+ // not opaque English identifiers like "answer_piano"
558
+ b24Btn.ACTION_VALUE = btn.text;
557
559
  }
558
560
 
559
561
  if (btn.style === 'primary') {
@@ -595,6 +597,39 @@ export function extractKeyboardFromPayload(
595
597
  return undefined;
596
598
  }
597
599
 
600
+ /**
601
+ * Extract inline button JSON embedded in message text by the agent.
602
+ *
603
+ * Agents (especially GPT-4o) sometimes embed button markup directly in text
604
+ * as `[[{...},{...}]]` instead of using tool call parameters.
605
+ * This function detects such patterns, extracts the keyboard, and returns
606
+ * cleaned text without the JSON fragment.
607
+ */
608
+ export function extractInlineButtonsFromText(
609
+ text: string,
610
+ ): { cleanText: string; keyboard: B24Keyboard } | undefined {
611
+ // Match [[...]] containing JSON array of button objects
612
+ const match = text.match(/\[\[\s*(\{[\s\S]*?\}(?:\s*,\s*\{[\s\S]*?\})*)\s*\]\]/);
613
+ if (!match) return undefined;
614
+
615
+ try {
616
+ const parsed = JSON.parse(`[${match[1]}]`);
617
+ if (!Array.isArray(parsed) || parsed.length === 0) return undefined;
618
+
619
+ // Validate it looks like button objects (must have "text" property)
620
+ if (!parsed.every((b: unknown) => typeof b === 'object' && b !== null && 'text' in b)) return undefined;
621
+
622
+ // Wrap in rows array (single row)
623
+ const buttons: ChannelButton[][] = [parsed as ChannelButton[]];
624
+ const keyboard = convertButtonsToKeyboard(buttons);
625
+ const cleanText = text.replace(match[0], '').trim();
626
+
627
+ return { cleanText, keyboard };
628
+ } catch {
629
+ return undefined;
630
+ }
631
+ }
632
+
598
633
  function normalizeCommandReplyPayload(params: {
599
634
  commandName: string;
600
635
  commandParams: string;
@@ -1066,7 +1101,17 @@ export const bitrix24Plugin = {
1066
1101
  }) => {
1067
1102
  const sendCtx = resolveOutboundSendCtx(ctx);
1068
1103
  if (!sendCtx || !gatewayState) throw new Error('Bitrix24 gateway not started');
1069
- const result = await gatewayState.sendService.sendText(sendCtx, ctx.text);
1104
+
1105
+ // Agent may embed button JSON in text as [[{...}]]
1106
+ let text = ctx.text;
1107
+ let keyboard: B24Keyboard | undefined;
1108
+ const extracted = extractInlineButtonsFromText(text);
1109
+ if (extracted) {
1110
+ text = extracted.cleanText;
1111
+ keyboard = extracted.keyboard;
1112
+ }
1113
+
1114
+ const result = await gatewayState.sendService.sendText(sendCtx, text, keyboard ? { keyboard } : undefined);
1070
1115
  return { messageId: String(result.messageId ?? '') };
1071
1116
  },
1072
1117
 
@@ -1152,11 +1197,11 @@ export const bitrix24Plugin = {
1152
1197
 
1153
1198
  actions: {
1154
1199
  listActions: (_params: { cfg: Record<string, unknown> }): string[] => {
1155
- return ['react'];
1200
+ return ['react', 'send'];
1156
1201
  },
1157
1202
 
1158
1203
  supportsAction: (params: { action: string }): boolean => {
1159
- return params.action === 'react';
1204
+ return params.action === 'react' || params.action === 'send';
1160
1205
  },
1161
1206
 
1162
1207
  handleAction: async (ctx: {
@@ -1167,6 +1212,59 @@ export const bitrix24Plugin = {
1167
1212
  params: Record<string, unknown>;
1168
1213
  [key: string]: unknown;
1169
1214
  }): Promise<Record<string, unknown> | null> => {
1215
+ // ─── Send with buttons ──────────────────────────────────────────────
1216
+ if (ctx.action === 'send') {
1217
+ // Only intercept send when buttons are present; otherwise let gateway handle normally
1218
+ const rawButtons = ctx.params.buttons;
1219
+ if (!rawButtons) return null;
1220
+
1221
+ const { config } = resolveAccount(ctx.cfg, ctx.accountId);
1222
+ if (!config.webhookUrl || !gatewayState) return null;
1223
+
1224
+ const bot = gatewayState.bot;
1225
+ const to = String(ctx.params.to ?? ctx.to ?? '').trim();
1226
+ if (!to) {
1227
+ defaultLogger.warn('handleAction send: no "to" in params or ctx, falling back to gateway');
1228
+ return null;
1229
+ }
1230
+
1231
+ const sendCtx: SendContext = { webhookUrl: config.webhookUrl, bot, dialogId: to };
1232
+ const message = String(ctx.params.message ?? '').trim();
1233
+
1234
+ // Parse buttons: may be array or JSON string
1235
+ let buttons: ChannelButton[][] | undefined;
1236
+ try {
1237
+ const parsed = typeof rawButtons === 'string' ? JSON.parse(rawButtons) : rawButtons;
1238
+ if (Array.isArray(parsed)) buttons = parsed;
1239
+ } catch {
1240
+ // invalid buttons JSON — send without keyboard
1241
+ }
1242
+
1243
+ const keyboard = buttons?.length ? convertButtonsToKeyboard(buttons) : undefined;
1244
+
1245
+ const toolResult = (payload: Record<string, unknown>) => ({
1246
+ content: [{ type: 'text' as const, text: JSON.stringify(payload, null, 2) }],
1247
+ details: payload,
1248
+ });
1249
+
1250
+ try {
1251
+ const result = await gatewayState.sendService.sendText(
1252
+ sendCtx, message || ' ', keyboard ? { keyboard } : undefined,
1253
+ );
1254
+ return toolResult({
1255
+ channel: 'bitrix24',
1256
+ to,
1257
+ via: 'direct',
1258
+ mediaUrl: null,
1259
+ result: { messageId: String(result.messageId ?? '') },
1260
+ });
1261
+ } catch (err) {
1262
+ const errMsg = err instanceof Error ? err.message : String(err);
1263
+ return toolResult({ ok: false, error: errMsg });
1264
+ }
1265
+ }
1266
+
1267
+ // ─── React ────────────────────────────────────────────────────────────
1170
1268
  if (ctx.action !== 'react') return null;
1171
1269
 
1172
1270
  // Helper: wrap payload as gateway-compatible tool result
@@ -1453,8 +1551,19 @@ export const bitrix24Plugin = {
1453
1551
  }
1454
1552
  }
1455
1553
  if (payload.text) {
1456
- const keyboard = extractKeyboardFromPayload(payload);
1457
- await sendService.sendText(sendCtx, payload.text, keyboard ? { keyboard } : undefined);
1554
+ let text = payload.text;
1555
+ let keyboard = extractKeyboardFromPayload(payload);
1556
+
1557
+ // Fallback: agent may embed button JSON in text as [[{...}]]
1558
+ if (!keyboard) {
1559
+ const extracted = extractInlineButtonsFromText(text);
1560
+ if (extracted) {
1561
+ text = extracted.cleanText;
1562
+ keyboard = extracted.keyboard;
1563
+ }
1564
+ }
1565
+
1566
+ await sendService.sendText(sendCtx, text, keyboard ? { keyboard } : undefined);
1458
1567
  }
1459
1568
  },
1460
1569
  onReplyStart: async () => {
@@ -97,7 +97,7 @@ describe('convertButtonsToKeyboard', () => {
97
97
  }
98
98
  });
99
99
 
100
- it('converts non-slash callback_data to ACTION PUT', () => {
100
+ it('converts non-slash callback_data to ACTION SEND with text as value', () => {
101
101
  const kb = convertButtonsToKeyboard([
102
102
  [{ text: 'Click me', callback_data: 'some_value' }],
103
103
  ]);
@@ -105,8 +105,8 @@ describe('convertButtonsToKeyboard', () => {
105
105
  const btn = kb[0];
106
106
  if (isButton(btn)) {
107
107
  expect(btn.COMMAND).toBeUndefined();
108
- expect(btn.ACTION).toBe('PUT');
109
- expect(btn.ACTION_VALUE).toBe('some_value');
108
+ expect(btn.ACTION).toBe('SEND');
109
+ expect(btn.ACTION_VALUE).toBe('Click me');
110
110
  }
111
111
  });
112
112