@ihazz/bitrix24 0.2.4 → 1.0.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.
@@ -1,34 +1,53 @@
1
- import qs from 'qs';
1
+ import { parse as parseQueryString } from 'qs';
2
2
  import type {
3
- B24Event,
4
- B24MessageEvent,
5
- B24JoinChatEvent,
6
- B24CommandEvent,
7
- B24AppInstallEvent,
8
- B24BotEntry,
9
- B24File,
3
+ B24V2FetchEventItem,
4
+ B24V2WebhookEvent,
5
+ B24V2MessageEventData,
6
+ B24V2JoinChatEventData,
7
+ B24V2CommandEventData,
8
+ B24V2DeleteEventData,
9
+ B24V2Message,
10
10
  B24MsgContext,
11
11
  B24MediaItem,
12
+ FetchContext,
13
+ Bitrix24AccountConfig,
14
+ Logger,
12
15
  } from './types.js';
13
16
  import { Dedup } from './dedup.js';
14
- import type { Bitrix24AccountConfig } from './types.js';
15
17
  import { defaultLogger } from './utils.js';
16
18
 
17
- interface Logger {
18
- info: (...args: unknown[]) => void;
19
- warn: (...args: unknown[]) => void;
20
- error: (...args: unknown[]) => void;
21
- debug: (...args: unknown[]) => void;
19
+ /** Normalized fetch command context passed to onFetchCommand callback. */
20
+ export interface FetchCommandContext {
21
+ commandId: number;
22
+ commandName: string;
23
+ commandParams: string;
24
+ commandText: string;
25
+ senderId: string;
26
+ dialogId: string;
27
+ chatType: string;
28
+ messageId: string;
29
+ language?: string;
30
+ fetchCtx: FetchContext;
31
+ }
32
+
33
+ /** Normalized fetch join chat context */
34
+ export interface FetchJoinChatContext {
35
+ dialogId: string;
36
+ chatType: string;
37
+ language?: string;
38
+ fetchCtx: FetchContext;
22
39
  }
23
40
 
24
41
  export interface InboundHandlerOptions {
25
42
  config: Bitrix24AccountConfig;
26
43
  logger?: Logger;
27
44
  onMessage?: (ctx: B24MsgContext) => void | Promise<void>;
28
- onJoinChat?: (event: B24JoinChatEvent) => void | Promise<void>;
29
- onCommand?: (event: B24CommandEvent) => void | Promise<void>;
30
- onAppInstall?: (event: B24AppInstallEvent) => void | Promise<void>;
31
- onBotDelete?: (event: B24Event) => void | Promise<void>;
45
+ /** Called when bot is invited to a chat (FETCH or webhook). */
46
+ onJoinChat?: (ctx: FetchJoinChatContext) => void | Promise<void>;
47
+ /** Called for a slash command. */
48
+ onCommand?: (cmdCtx: FetchCommandContext) => void | Promise<void>;
49
+ /** Called when bot is deleted. */
50
+ onBotDelete?: (data: B24V2DeleteEventData) => void | Promise<void>;
32
51
  }
33
52
 
34
53
  export class InboundHandler {
@@ -36,10 +55,9 @@ export class InboundHandler {
36
55
  private config: Bitrix24AccountConfig;
37
56
  private logger: Logger;
38
57
  private onMessage?: (ctx: B24MsgContext) => void | Promise<void>;
39
- private onJoinChat?: (event: B24JoinChatEvent) => void | Promise<void>;
40
- private onCommand?: (event: B24CommandEvent) => void | Promise<void>;
41
- private onAppInstall?: (event: B24AppInstallEvent) => void | Promise<void>;
42
- private onBotDelete?: (event: B24Event) => void | Promise<void>;
58
+ private onJoinChat?: (ctx: FetchJoinChatContext) => void | Promise<void>;
59
+ private onCommand?: (cmdCtx: FetchCommandContext) => void | Promise<void>;
60
+ private onBotDelete?: (data: B24V2DeleteEventData) => void | Promise<void>;
43
61
 
44
62
  constructor(opts: InboundHandlerOptions) {
45
63
  this.dedup = new Dedup();
@@ -48,132 +66,310 @@ export class InboundHandler {
48
66
  this.onMessage = opts.onMessage;
49
67
  this.onJoinChat = opts.onJoinChat;
50
68
  this.onCommand = opts.onCommand;
51
- this.onAppInstall = opts.onAppInstall;
52
69
  this.onBotDelete = opts.onBotDelete;
53
70
  }
54
71
 
72
+ // ─── V2 FETCH mode event handling ───────────────────────────────────────
73
+
55
74
  /**
56
- * Handle a raw incoming webhook request body.
57
- * B24 sends application/x-www-form-urlencoded or JSON.
58
- *
59
- * @param rawBody - Either a parsed object or a URL-encoded string
60
- * @returns true if the event was handled
75
+ * Handle a single event item from imbot.v2.Event.get (FETCH mode).
76
+ * V2 events have camelCase fields and nested bot/message/chat/user objects.
61
77
  */
62
- async handleWebhook(rawBody: string | Record<string, unknown>): Promise<boolean> {
63
- const payload = typeof rawBody === 'string'
64
- ? (qs.parse(rawBody, { depth: 10 }) as Record<string, unknown>)
65
- : rawBody;
78
+ async handleFetchEvent(item: B24V2FetchEventItem, fetchCtx: FetchContext): Promise<boolean> {
79
+ const eventType = item.type;
80
+ this.logger.debug(`Fetch event: ${eventType} (id=${item.eventId})`);
66
81
 
67
- const event = payload as unknown as B24Event;
68
- const eventType = event.event;
82
+ switch (eventType) {
83
+ case 'ONIMBOTV2MESSAGEADD':
84
+ return this.handleV2Message(item, fetchCtx);
69
85
 
70
- if (!eventType) {
71
- this.logger.warn('Received webhook without event type', payload);
72
- return false;
73
- }
86
+ case 'ONIMBOTV2JOINCHAT':
87
+ return this.handleV2JoinChat(item, fetchCtx);
74
88
 
75
- this.logger.debug(`Received event: ${eventType}`);
89
+ case 'ONIMBOTV2COMMANDADD':
90
+ return this.handleV2Command(item, fetchCtx);
76
91
 
77
- switch (eventType) {
78
- case 'ONIMBOTMESSAGEADD':
79
- return this.handleMessage(event as B24MessageEvent);
80
- case 'ONIMBOTJOINCHAT':
81
- await this.onJoinChat?.(event as B24JoinChatEvent);
82
- return true;
83
- case 'ONIMCOMMANDADD':
84
- await this.onCommand?.(event as B24CommandEvent);
92
+ case 'ONIMBOTV2DELETE':
93
+ await this.onBotDelete?.(item.data as B24V2DeleteEventData);
85
94
  return true;
86
- case 'ONAPPINSTALL':
87
- await this.onAppInstall?.(event as B24AppInstallEvent);
88
- return true;
89
- case 'ONIMBOTDELETE':
90
- await this.onBotDelete?.(event);
95
+
96
+ case 'ONIMBOTV2MESSAGEUPDATE':
97
+ case 'ONIMBOTV2MESSAGEDELETE':
98
+ case 'ONIMBOTV2CONTEXTGET':
99
+ case 'ONIMBOTV2REACTIONCHANGE':
100
+ this.logger.debug(`Fetch: skipping ${eventType} (not handled)`);
91
101
  return true;
102
+
92
103
  default:
93
- this.logger.debug(`Unhandled event type: ${eventType}`);
94
- return false;
104
+ this.logger.debug(`Fetch: unhandled event type ${eventType}`);
105
+ return true;
95
106
  }
96
107
  }
97
108
 
98
109
  /**
99
- * Handle ONIMBOTMESSAGEADD: normalize to B24MsgContext and dispatch.
110
+ * Handle V2 ONIMBOTV2MESSAGEADD event.
111
+ * V2 data: { bot, message, chat, user, language }
100
112
  */
101
- private async handleMessage(event: B24MessageEvent): Promise<boolean> {
102
- const params = event.data.PARAMS;
103
- const messageId = params.MESSAGE_ID;
113
+ private async handleV2Message(
114
+ item: B24V2FetchEventItem,
115
+ fetchCtx: FetchContext,
116
+ ): Promise<boolean> {
117
+ const data = item.data as B24V2MessageEventData;
118
+
119
+ // Runtime guard: ensure essential V2 data fields exist
120
+ if (!data?.message || !data?.user) {
121
+ this.logger.warn('Fetch: MESSAGEADD event missing message or user data, skipping', { eventId: item.eventId });
122
+ return true;
123
+ }
124
+
125
+ const rawMessageId = data.message.id ?? item.eventId;
126
+ if (rawMessageId == null) {
127
+ this.logger.warn('Fetch: MESSAGEADD event missing both message.id and eventId, skipping');
128
+ return true;
129
+ }
130
+ const messageId = String(rawMessageId);
104
131
 
105
- // Dedup check
106
132
  if (this.dedup.isDuplicate(messageId)) {
107
- this.logger.debug(`Duplicate message ${messageId}, skipping`);
133
+ this.logger.debug(`Fetch: duplicate message ${messageId}, skipping`);
108
134
  return true;
109
135
  }
110
136
 
111
- // Extract bot entry
112
- const botEntry = extractBotEntry(event.data.BOT);
113
- if (!botEntry) {
114
- this.logger.error('No bot entry found in event');
115
- return false;
137
+ const dialogId = String(data.chat?.dialogId ?? data.message.chatId ?? data.user.id ?? '');
138
+ if (!dialogId) {
139
+ this.logger.warn('Fetch: message event has no dialogId, skipping');
140
+ return true;
116
141
  }
142
+ const isP2P = !dialogId.startsWith('chat');
143
+
144
+ // Extract file attachments from message params
145
+ const media = extractFilesFromParams(data.message.params);
117
146
 
118
- // Normalize to B24MsgContext
119
- const ctx = normalizeMessageEvent(event, botEntry);
147
+ this.logger.info('Fetch: message payload', {
148
+ eventId: item.eventId,
149
+ messageId,
150
+ dialogId,
151
+ chatId: data.message.chatId,
152
+ forward: data.message.forward,
153
+ params: data.message.params,
154
+ text: data.message.text,
155
+ });
156
+
157
+ const ctx: B24MsgContext = {
158
+ channel: 'bitrix24',
159
+ senderId: String(data.user?.id ?? dialogId),
160
+ senderName: data.user?.name ?? dialogId,
161
+ senderFirstName: data.user?.firstName,
162
+ chatId: dialogId,
163
+ chatInternalId: String(data.message?.chatId ?? dialogId),
164
+ messageId,
165
+ replyToMessageId: extractReplyToMessageId(data.message.params),
166
+ isForwarded: isForwardedMessage(data.message.forward),
167
+ text: data.message?.text ?? '',
168
+ isDm: isP2P,
169
+ isGroup: !isP2P,
170
+ media,
171
+ language: data.language,
172
+ raw: item,
173
+ botId: fetchCtx.botId,
174
+ memberId: '',
175
+ };
120
176
 
121
- // Dispatch to handler
122
177
  await this.onMessage?.(ctx);
123
178
  return true;
124
179
  }
125
180
 
181
+ /**
182
+ * Handle V2 ONIMBOTV2JOINCHAT event.
183
+ */
184
+ private async handleV2JoinChat(
185
+ item: B24V2FetchEventItem,
186
+ fetchCtx: FetchContext,
187
+ ): Promise<boolean> {
188
+ const data = item.data as B24V2JoinChatEventData;
189
+
190
+ if (!data?.chat || !data?.dialogId) {
191
+ this.logger.warn('Fetch: JOINCHAT event missing chat or dialogId, skipping', { eventId: item.eventId });
192
+ return true;
193
+ }
194
+
195
+ const joinCtx: FetchJoinChatContext = {
196
+ dialogId: data.dialogId,
197
+ chatType: data.chat.type,
198
+ language: data.language,
199
+ fetchCtx,
200
+ };
201
+
202
+ await this.onJoinChat?.(joinCtx);
203
+ return true;
204
+ }
205
+
206
+ /**
207
+ * Handle V2 ONIMBOTV2COMMANDADD event.
208
+ * V2 data: { bot, command, message, chat, user, language }
209
+ */
210
+ private async handleV2Command(
211
+ item: B24V2FetchEventItem,
212
+ fetchCtx: FetchContext,
213
+ ): Promise<boolean> {
214
+ const data = item.data as B24V2CommandEventData;
215
+
216
+ const rawCommand = typeof data.command?.command === 'string' ? data.command.command : '';
217
+ const commandName = rawCommand.replace(/^\//, '');
218
+ if (!commandName) {
219
+ this.logger.warn('Fetch: command event has no command name, skipping');
220
+ return true;
221
+ }
222
+ const commandId = Number(data.command?.id ?? 0);
223
+ if (!Number.isFinite(commandId) || commandId <= 0) {
224
+ this.logger.warn('Fetch: command event has no commandId, skipping');
225
+ return true;
226
+ }
227
+
228
+ const commandParams = typeof data.command?.params === 'string'
229
+ ? data.command.params.trim()
230
+ : '';
231
+ const commandText = commandParams
232
+ ? `/${commandName} ${commandParams}`
233
+ : `/${commandName}`;
234
+
235
+ const dialogId = String(data.chat?.dialogId ?? data.message?.chatId ?? data.user?.id ?? '');
236
+ if (!dialogId) {
237
+ this.logger.warn('Fetch: command event has no dialogId, skipping');
238
+ return true;
239
+ }
240
+ const isP2P = !dialogId.startsWith('chat');
241
+
242
+ const cmdCtx: FetchCommandContext = {
243
+ commandId,
244
+ commandName,
245
+ commandParams,
246
+ commandText,
247
+ senderId: String(data.user?.id ?? dialogId),
248
+ dialogId,
249
+ chatType: isP2P ? 'P' : String(data.chat?.type ?? ''),
250
+ messageId: String(data.message?.id ?? item.eventId),
251
+ language: data.language,
252
+ fetchCtx,
253
+ };
254
+
255
+ await this.onCommand?.(cmdCtx);
256
+ return true;
257
+ }
258
+
259
+ // ─── V2 Webhook mode event handling ─────────────────────────────────────
260
+
261
+ /**
262
+ * Handle an incoming V2 webhook POST body.
263
+ * V2 webhooks deliver JSON with { event, data } structure.
264
+ */
265
+ async handleWebhook(rawBody: string | Record<string, unknown>): Promise<boolean> {
266
+ let payload: B24V2WebhookEvent;
267
+
268
+ if (typeof rawBody === 'string') {
269
+ try {
270
+ payload = JSON.parse(rawBody) as B24V2WebhookEvent;
271
+ } catch {
272
+ const parsedBody = parseQueryString(rawBody, {
273
+ allowDots: true,
274
+ depth: 10,
275
+ parseArrays: true,
276
+ });
277
+
278
+ if (!parsedBody || typeof parsedBody !== 'object' || !('event' in parsedBody)) {
279
+ this.logger.warn('Failed to parse V2 webhook body');
280
+ return false;
281
+ }
282
+
283
+ payload = parsedBody as unknown as B24V2WebhookEvent;
284
+ }
285
+ } else if (rawBody && typeof rawBody === 'object' && 'event' in rawBody) {
286
+ payload = rawBody as unknown as B24V2WebhookEvent;
287
+ } else {
288
+ this.logger.warn('Webhook body missing "event" field', { type: typeof rawBody });
289
+ return false;
290
+ }
291
+
292
+ const eventType = payload.event;
293
+ if (!eventType) {
294
+ this.logger.warn('Received webhook without event type', payload);
295
+ return false;
296
+ }
297
+
298
+ this.logger.debug(`Received V2 webhook event: ${eventType}`);
299
+
300
+ // Convert webhook event to fetch event format and reuse handlers
301
+ // V2 webhook data has same structure as fetch events
302
+ const syntheticItem: B24V2FetchEventItem = {
303
+ eventId: 0,
304
+ type: eventType,
305
+ date: new Date().toISOString(),
306
+ data: payload.data,
307
+ };
308
+
309
+ // For webhook mode we need botId from the bot object in the event
310
+ const botData = payload.data as { bot?: { id?: number } };
311
+ const botId = botData?.bot?.id ?? 0;
312
+
313
+ const fetchCtx: FetchContext = {
314
+ webhookUrl: this.config.webhookUrl ?? '',
315
+ botId,
316
+ botToken: this.config.botToken ?? '',
317
+ };
318
+
319
+ return this.handleFetchEvent(syntheticItem, fetchCtx);
320
+ }
321
+
126
322
  destroy(): void {
127
323
  this.dedup.destroy();
128
324
  }
129
325
  }
130
326
 
131
- // ─── Normalization helpers ─────────────────────────────────────────────────
327
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
132
328
 
133
- function extractBotEntry(botMap: Record<string, B24BotEntry>): B24BotEntry | null {
134
- const entries = Object.values(botMap);
135
- return entries[0] ?? null;
329
+ /**
330
+ * Extract media items from V2 message params.
331
+ * In V2, file info may come in params.FILE_ID array.
332
+ * File details are available via imbot.v2.File.download.
333
+ */
334
+ function extractFilesFromParams(params: Record<string, unknown>): B24MediaItem[] {
335
+ // V2 events may include file IDs in params (array or single value)
336
+ const rawFileIds = params?.FILE_ID;
337
+ if (rawFileIds == null) return [];
338
+ const fileIds = Array.isArray(rawFileIds) ? rawFileIds : [rawFileIds];
339
+ if (!fileIds.length) return [];
340
+
341
+ return fileIds.map((id) => ({
342
+ id: String(id),
343
+ name: `file_${id}`,
344
+ extension: '',
345
+ size: 0,
346
+ type: 'file' as const,
347
+ }));
136
348
  }
137
349
 
138
- export function normalizeMessageEvent(
139
- event: B24MessageEvent,
140
- botEntry: B24BotEntry,
141
- ): B24MsgContext {
142
- const params = event.data.PARAMS;
143
- const user = event.data.USER;
144
-
145
- return {
146
- channel: 'bitrix24',
147
- senderId: String(user.ID),
148
- senderName: user.NAME,
149
- senderFirstName: user.FIRST_NAME,
150
- chatId: params.DIALOG_ID,
151
- chatInternalId: String(params.TO_CHAT_ID),
152
- messageId: String(params.MESSAGE_ID),
153
- text: params.MESSAGE || '',
154
- isDm: params.CHAT_TYPE === 'P',
155
- isGroup: params.CHAT_TYPE !== 'P',
156
- media: normalizeFiles(params.FILES),
157
- platform: params.PLATFORM_CONTEXT,
158
- language: params.LANGUAGE,
159
- raw: event,
160
- botToken: botEntry.access_token,
161
- userToken: event.auth.access_token,
162
- clientEndpoint: botEntry.client_endpoint,
163
- botId: botEntry.BOT_ID,
164
- memberId: event.auth.member_id,
165
- };
350
+ function extractReplyToMessageId(params: Record<string, unknown>): string | undefined {
351
+ const rawReplyId = params?.REPLY_ID;
352
+ if (rawReplyId == null) {
353
+ return undefined;
354
+ }
355
+
356
+ if (Array.isArray(rawReplyId)) {
357
+ const firstReplyId = rawReplyId.find((value) => value != null && String(value).trim() !== '');
358
+ return firstReplyId == null ? undefined : String(firstReplyId).trim();
359
+ }
360
+
361
+ const normalizedReplyId = String(rawReplyId).trim();
362
+ return normalizedReplyId.length > 0 ? normalizedReplyId : undefined;
166
363
  }
167
364
 
168
- function normalizeFiles(files?: Record<string, B24File>): B24MediaItem[] {
169
- if (!files) return [];
365
+ function isForwardedMessage(forward: unknown): boolean {
366
+ if (forward == null) {
367
+ return false;
368
+ }
170
369
 
171
- return Object.values(files).map((file) => ({
172
- id: String(file.id),
173
- name: file.name,
174
- extension: file.extension,
175
- size: file.size,
176
- type: file.image ? 'image' as const : 'file' as const,
177
- urlDownload: file.urlDownload || undefined,
178
- }));
370
+ if (typeof forward === 'string') {
371
+ return forward.trim().length > 0;
372
+ }
373
+
374
+ return true;
179
375
  }