@ihazz/bitrix24 0.2.5 → 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.
@@ -0,0 +1,240 @@
1
+ import { writeFile, readFile, mkdir, rename } from 'node:fs/promises';
2
+ import { join, dirname } from 'node:path';
3
+ import { homedir } from 'node:os';
4
+ import { randomBytes } from 'node:crypto';
5
+ import type { Bitrix24Api, BotContext } from './api.js';
6
+ import type { B24V2FetchEventItem, Logger } from './types.js';
7
+ import { Bitrix24ApiError } from './utils.js';
8
+
9
+ export interface PollingServiceOptions {
10
+ api: Bitrix24Api;
11
+ webhookUrl: string;
12
+ bot: BotContext;
13
+ accountId: string;
14
+ pollingIntervalMs: number;
15
+ pollingFastIntervalMs: number;
16
+ onEvent: (event: B24V2FetchEventItem) => Promise<void>;
17
+ abortSignal: AbortSignal;
18
+ logger: Logger;
19
+ }
20
+
21
+ interface OffsetState {
22
+ offset: number;
23
+ updatedAt: string;
24
+ }
25
+
26
+ /** Exponential backoff with jitter for recoverable errors. */
27
+ class ExponentialBackoff {
28
+ private currentMs: number;
29
+
30
+ constructor(
31
+ private readonly initialMs = 2000,
32
+ private readonly maxMs = 30000,
33
+ private readonly factor = 1.8,
34
+ private readonly jitter = 0.25,
35
+ ) {
36
+ this.currentMs = initialMs;
37
+ }
38
+
39
+ next(): number {
40
+ const base = this.currentMs;
41
+ const jitterRange = base * this.jitter;
42
+ const delay = base + (Math.random() * 2 - 1) * jitterRange;
43
+ this.currentMs = Math.min(this.currentMs * this.factor, this.maxMs);
44
+ return Math.max(delay, 0);
45
+ }
46
+
47
+ reset(): void {
48
+ this.currentMs = this.initialMs;
49
+ }
50
+ }
51
+
52
+ /** Fatal errors that should stop the polling loop. */
53
+ const FATAL_ERROR_CODES = new Set([
54
+ 'ACCESS_DENIED',
55
+ 'BOT_NOT_FOUND',
56
+ 'BOT_OWNERSHIP_ERROR',
57
+ 'AUTH_ERROR',
58
+ 'SCOPE_ERROR',
59
+ ]);
60
+
61
+ /** Errors that indicate B24 rate limiting. */
62
+ const RATE_LIMIT_CODE = 'QUERY_LIMIT_EXCEEDED';
63
+
64
+ export class PollingService {
65
+ private readonly api: Bitrix24Api;
66
+ private readonly webhookUrl: string;
67
+ private readonly bot: BotContext;
68
+ private readonly accountId: string;
69
+ private readonly pollingIntervalMs: number;
70
+ private readonly pollingFastIntervalMs: number;
71
+ private readonly onEvent: (event: B24V2FetchEventItem) => Promise<void>;
72
+ private readonly abortSignal: AbortSignal;
73
+ private readonly logger: Logger;
74
+ private readonly offsetPath: string;
75
+
76
+ private offset = 0;
77
+ private rateLimitStreak = 0;
78
+
79
+ constructor(opts: PollingServiceOptions) {
80
+ this.api = opts.api;
81
+ this.webhookUrl = opts.webhookUrl;
82
+ this.bot = opts.bot;
83
+ this.accountId = opts.accountId;
84
+ this.pollingIntervalMs = opts.pollingIntervalMs;
85
+ this.pollingFastIntervalMs = opts.pollingFastIntervalMs;
86
+ this.onEvent = opts.onEvent;
87
+ this.abortSignal = opts.abortSignal;
88
+ this.logger = opts.logger;
89
+
90
+ const stateDir = join(homedir(), '.openclaw', 'state', 'bitrix24');
91
+ this.offsetPath = join(stateDir, `poll-offset-${this.accountId}.json`);
92
+ }
93
+
94
+ /**
95
+ * Start the blocking polling loop.
96
+ * Uses imbot.v2.Event.get which returns { events, lastEventId, hasMore }.
97
+ */
98
+ async start(): Promise<void> {
99
+ this.offset = await this.loadOffset();
100
+ this.logger.info(`Polling started (botId=${this.bot.botId}, offset=${this.offset})`);
101
+
102
+ const backoff = new ExponentialBackoff();
103
+
104
+ while (!this.abortSignal.aborted) {
105
+ try {
106
+ const result = await this.api.fetchEvents(this.webhookUrl, this.bot, {
107
+ offset: this.offset,
108
+ limit: 100,
109
+ });
110
+
111
+ // Successful fetch — reset backoff and rate limit streak
112
+ backoff.reset();
113
+ this.rateLimitStreak = 0;
114
+
115
+ const { events, lastEventId, hasMore } = result;
116
+ let nextOffset = this.offset;
117
+
118
+ // Process events sequentially
119
+ for (const event of events) {
120
+ if (this.abortSignal.aborted) break;
121
+ try {
122
+ await this.onEvent(event);
123
+ nextOffset = Math.max(nextOffset, event.eventId + 1);
124
+ } catch (err) {
125
+ if (nextOffset > this.offset) {
126
+ this.offset = nextOffset;
127
+ await this.persistOffset(this.offset);
128
+ }
129
+ throw err;
130
+ }
131
+ }
132
+
133
+ // Advance offset only for events that were processed successfully.
134
+ if (events.length > 0 && nextOffset > this.offset) {
135
+ this.offset = nextOffset;
136
+ await this.persistOffset(this.offset);
137
+ } else if (events.length > 0 && lastEventId < this.offset) {
138
+ this.logger.warn('Polling: lastEventId regressed', {
139
+ lastEventId,
140
+ currentOffset: this.offset,
141
+ eventCount: events.length,
142
+ });
143
+ }
144
+
145
+ // If there are more events, fetch again quickly
146
+ if (hasMore) {
147
+ await this.sleepWithAbort(this.pollingFastIntervalMs);
148
+ } else {
149
+ await this.sleepWithAbort(this.pollingIntervalMs);
150
+ }
151
+ } catch (err) {
152
+ if (this.abortSignal.aborted) break;
153
+
154
+ // Handle B24 API errors
155
+ if (err instanceof Bitrix24ApiError) {
156
+ // Fatal errors — stop polling
157
+ if (FATAL_ERROR_CODES.has(err.code)) {
158
+ this.logger.error(`Fatal polling error (${err.code}): ${err.description} — stopping`);
159
+ throw err;
160
+ }
161
+
162
+ // Rate limit — short pause without exponential backoff
163
+ if (err.code === RATE_LIMIT_CODE) {
164
+ this.rateLimitStreak++;
165
+ const pauseMs = this.rateLimitStreak >= 3 ? 5000 : 2000;
166
+ this.logger.warn(`Rate limited (streak=${this.rateLimitStreak}), pausing ${pauseMs}ms`);
167
+ await this.sleepWithAbort(pauseMs);
168
+ continue;
169
+ }
170
+ }
171
+
172
+ // HTTP errors and network errors — recoverable with backoff
173
+ const delayMs = backoff.next();
174
+ const errMsg = err instanceof Error ? err.message : String(err);
175
+ this.logger.warn(`Polling error, retrying in ${Math.round(delayMs)}ms: ${errMsg}`);
176
+ await this.sleepWithAbort(delayMs);
177
+ }
178
+ }
179
+
180
+ this.logger.info('Polling stopped');
181
+ }
182
+
183
+ // ─── Offset persistence ──────────────────────────────────────────────
184
+
185
+ private async loadOffset(): Promise<number> {
186
+ try {
187
+ const raw = await readFile(this.offsetPath, 'utf-8');
188
+ const state: OffsetState = JSON.parse(raw);
189
+ if (typeof state.offset === 'number' && state.offset >= 0) {
190
+ this.logger.info(`Loaded saved offset: ${state.offset}`);
191
+ return state.offset;
192
+ }
193
+ this.logger.debug('Saved offset invalid, starting from 0', { raw: state });
194
+ } catch (err) {
195
+ // File doesn't exist or is corrupted — start from 0
196
+ this.logger.debug('Failed to load saved offset, starting from 0', err);
197
+ }
198
+ return 0;
199
+ }
200
+
201
+ private async persistOffset(offset: number): Promise<void> {
202
+ try {
203
+ const dir = dirname(this.offsetPath);
204
+ await mkdir(dir, { recursive: true });
205
+
206
+ const state: OffsetState = {
207
+ offset,
208
+ updatedAt: new Date().toISOString(),
209
+ };
210
+ const data = JSON.stringify(state, null, 2);
211
+
212
+ // Atomic write: tmp file + rename
213
+ const tmpPath = `${this.offsetPath}.${randomBytes(4).toString('hex')}.tmp`;
214
+ await writeFile(tmpPath, data, 'utf-8');
215
+ await rename(tmpPath, this.offsetPath);
216
+ } catch (err) {
217
+ this.logger.warn('Failed to persist offset', err);
218
+ }
219
+ }
220
+
221
+ // ─── Sleep with abort ────────────────────────────────────────────────
222
+
223
+ private sleepWithAbort(ms: number): Promise<void> {
224
+ if (this.abortSignal.aborted) return Promise.resolve();
225
+
226
+ return new Promise<void>((resolve) => {
227
+ const timer = setTimeout(() => {
228
+ this.abortSignal.removeEventListener('abort', onAbort);
229
+ resolve();
230
+ }, ms);
231
+
232
+ const onAbort = () => {
233
+ clearTimeout(timer);
234
+ resolve();
235
+ };
236
+
237
+ this.abortSignal.addEventListener('abort', onAbort, { once: true });
238
+ });
239
+ }
240
+ }
@@ -7,8 +7,9 @@ export class RateLimiter {
7
7
  private readonly maxTokens: number;
8
8
  private readonly refillRate: number;
9
9
  private lastRefill: number;
10
- private queue: Array<() => void> = [];
10
+ private queue: Array<{ resolve: () => void; reject: (err: Error) => void }> = [];
11
11
  private drainTimer: ReturnType<typeof setTimeout> | null = null;
12
+ private destroyed = false;
12
13
 
13
14
  constructor(opts: { maxPerSecond?: number } = {}) {
14
15
  const maxPerSecond = opts.maxPerSecond ?? 2;
@@ -26,6 +27,8 @@ export class RateLimiter {
26
27
  }
27
28
 
28
29
  async acquire(): Promise<void> {
30
+ if (this.destroyed) throw new Error('RateLimiter destroyed');
31
+
29
32
  this.refill();
30
33
 
31
34
  if (this.tokens >= 1) {
@@ -33,8 +36,8 @@ export class RateLimiter {
33
36
  return;
34
37
  }
35
38
 
36
- return new Promise<void>((resolve) => {
37
- this.queue.push(resolve);
39
+ return new Promise<void>((resolve, reject) => {
40
+ this.queue.push({ resolve, reject });
38
41
  this.scheduleDrain();
39
42
  });
40
43
  }
@@ -50,7 +53,7 @@ export class RateLimiter {
50
53
  while (this.tokens >= 1 && this.queue.length > 0) {
51
54
  this.tokens -= 1;
52
55
  const next = this.queue.shift();
53
- next?.();
56
+ next?.resolve();
54
57
  }
55
58
 
56
59
  if (this.queue.length > 0) {
@@ -64,12 +67,14 @@ export class RateLimiter {
64
67
  }
65
68
 
66
69
  destroy(): void {
70
+ this.destroyed = true;
67
71
  if (this.drainTimer) {
68
72
  clearTimeout(this.drainTimer);
69
73
  this.drainTimer = null;
70
74
  }
71
- for (const resolve of this.queue) {
72
- resolve();
75
+ const err = new Error('RateLimiter destroyed');
76
+ for (const { reject } of this.queue) {
77
+ reject(err);
73
78
  }
74
79
  this.queue = [];
75
80
  }
@@ -1,27 +1,31 @@
1
- import type { Bitrix24AccountConfig, SendMessageResult, B24Keyboard } from './types.js';
1
+ import type {
2
+ Bitrix24AccountConfig,
3
+ SendMessageResult,
4
+ B24Keyboard,
5
+ B24InputActionStatusCode,
6
+ Logger,
7
+ } from './types.js';
8
+ import type { BotContext } from './api.js';
2
9
  import { Bitrix24Api } from './api.js';
3
10
  import { markdownToBbCode, splitMessage } from './message-utils.js';
4
- import { defaultLogger } from './utils.js';
11
+ import { defaultLogger, serializeError } from './utils.js';
5
12
 
6
- interface Logger {
7
- info: (...args: unknown[]) => void;
8
- warn: (...args: unknown[]) => void;
9
- error: (...args: unknown[]) => void;
10
- debug: (...args: unknown[]) => void;
13
+ export interface SendContext {
14
+ webhookUrl: string;
15
+ bot: BotContext;
16
+ dialogId: string;
11
17
  }
12
18
 
13
- interface SendContext {
14
- /** Webhook URL for webhook mode */
15
- webhookUrl?: string;
16
- /** Client endpoint + access token for event-token mode */
17
- clientEndpoint?: string;
18
- botToken?: string;
19
- dialogId: string;
19
+ export interface CommandSendContext extends SendContext {
20
+ commandId: number;
21
+ messageId: number;
22
+ commandDialogId?: string;
20
23
  }
21
24
 
22
25
  export class SendService {
23
26
  private api: Bitrix24Api;
24
27
  private logger: Logger;
28
+ private static readonly DEFAULT_ACTION_DURATION_SECONDS = 30;
25
29
 
26
30
  constructor(api: Bitrix24Api, logger?: Logger) {
27
31
  this.api = api;
@@ -42,39 +46,26 @@ export class SendService {
42
46
  : text;
43
47
 
44
48
  const chunks = splitMessage(convertedText);
49
+ if (chunks.length === 0) return { ok: true };
45
50
  let lastMessageId: number | undefined;
46
51
 
47
52
  for (let i = 0; i < chunks.length; i++) {
48
53
  const isLast = i === chunks.length - 1;
49
54
  const msgOptions = isLast && options?.keyboard
50
- ? { KEYBOARD: options.keyboard }
55
+ ? { keyboard: options.keyboard }
51
56
  : undefined;
52
57
 
53
58
  try {
54
- if (ctx.webhookUrl) {
55
- lastMessageId = await this.api.sendMessage(
56
- ctx.webhookUrl,
57
- ctx.dialogId,
58
- chunks[i],
59
- msgOptions,
60
- );
61
- } else if (ctx.clientEndpoint && ctx.botToken) {
62
- lastMessageId = await this.api.sendMessageWithToken(
63
- ctx.clientEndpoint,
64
- ctx.botToken,
65
- ctx.dialogId,
66
- chunks[i],
67
- msgOptions,
68
- );
69
- } else {
70
- return { ok: false, error: 'No webhook URL or bot token available' };
71
- }
59
+ lastMessageId = await this.api.sendMessage(
60
+ ctx.webhookUrl,
61
+ ctx.bot,
62
+ ctx.dialogId,
63
+ chunks[i],
64
+ msgOptions,
65
+ );
72
66
  } catch (error) {
73
- this.logger.error('Failed to send message', error);
74
- return {
75
- ok: false,
76
- error: error instanceof Error ? error.message : String(error),
77
- };
67
+ this.logger.error('Failed to send message', { error: serializeError(error) });
68
+ throw error;
78
69
  }
79
70
  }
80
71
 
@@ -82,22 +73,115 @@ export class SendService {
82
73
  }
83
74
 
84
75
  /**
85
- * Send typing indicator.
76
+ * Answer a native Bitrix24 command using imbot.v2.Command.answer.
77
+ * The first chunk uses the command token, remaining chunks fall back
78
+ * to regular chat messages in the same dialog.
86
79
  */
87
- async sendTyping(ctx: SendContext): Promise<void> {
80
+ async answerCommandText(
81
+ ctx: CommandSendContext,
82
+ text: string,
83
+ options?: { keyboard?: B24Keyboard; convertMarkdown?: boolean },
84
+ ): Promise<SendMessageResult> {
85
+ const convertedText = options?.convertMarkdown !== false
86
+ ? markdownToBbCode(text)
87
+ : text;
88
+
89
+ const chunks = splitMessage(convertedText);
90
+ if (chunks.length === 0) {
91
+ return { ok: false, error: 'Empty command answer' };
92
+ }
93
+
94
+ let lastMessageId: number | undefined;
95
+
88
96
  try {
89
- if (ctx.webhookUrl) {
90
- await this.api.sendTyping(ctx.webhookUrl, ctx.dialogId);
91
- } else if (ctx.clientEndpoint && ctx.botToken) {
92
- await this.api.sendTypingWithToken(
93
- ctx.clientEndpoint,
94
- ctx.botToken,
97
+ await this.api.answerCommand(
98
+ ctx.webhookUrl,
99
+ ctx.bot,
100
+ ctx.commandId,
101
+ ctx.messageId,
102
+ ctx.commandDialogId ?? ctx.dialogId,
103
+ chunks[0],
104
+ chunks.length === 1 && options?.keyboard
105
+ ? { keyboard: options.keyboard }
106
+ : undefined,
107
+ );
108
+ } catch (error) {
109
+ this.logger.error('Failed to answer command', { error: serializeError(error) });
110
+ throw error;
111
+ }
112
+
113
+ for (let i = 1; i < chunks.length; i++) {
114
+ const isLast = i === chunks.length - 1;
115
+ const msgOptions = isLast && options?.keyboard
116
+ ? { keyboard: options.keyboard }
117
+ : undefined;
118
+
119
+ try {
120
+ lastMessageId = await this.api.sendMessage(
121
+ ctx.webhookUrl,
122
+ ctx.bot,
95
123
  ctx.dialogId,
124
+ chunks[i],
125
+ msgOptions,
96
126
  );
127
+ } catch (error) {
128
+ this.logger.error('Failed to send command follow-up message', { error: serializeError(error) });
129
+ throw error;
97
130
  }
131
+ }
132
+
133
+ return { ok: true, messageId: lastMessageId };
134
+ }
135
+
136
+ /**
137
+ * Send a specific Bitrix24 bot activity status.
138
+ */
139
+ async sendStatus(
140
+ ctx: SendContext,
141
+ statusMessageCode: B24InputActionStatusCode,
142
+ duration = SendService.DEFAULT_ACTION_DURATION_SECONDS,
143
+ ): Promise<void> {
144
+ try {
145
+ await this.api.notifyInputAction(ctx.webhookUrl, ctx.bot, ctx.dialogId, {
146
+ statusMessageCode,
147
+ duration,
148
+ });
149
+ } catch (error) {
150
+ this.logger.debug('Failed to send input action', {
151
+ dialogId: ctx.dialogId,
152
+ statusMessageCode,
153
+ duration,
154
+ error: serializeError(error),
155
+ });
156
+ }
157
+ }
158
+
159
+ /**
160
+ * Send the default generic typing indicator.
161
+ */
162
+ async sendTyping(ctx: SendContext): Promise<void> {
163
+ try {
164
+ await this.api.notifyInputAction(ctx.webhookUrl, ctx.bot, ctx.dialogId);
98
165
  } catch (error) {
99
- // Typing indicator failure is non-critical
100
- this.logger.debug('Failed to send typing indicator', error);
166
+ this.logger.debug('Failed to send typing indicator', {
167
+ dialogId: ctx.dialogId,
168
+ error: serializeError(error),
169
+ });
170
+ }
171
+ }
172
+
173
+ /**
174
+ * Mark incoming messages as read.
175
+ */
176
+ async markRead(ctx: SendContext, messageId?: number): Promise<void> {
177
+ try {
178
+ await this.api.readMessage(ctx.webhookUrl, ctx.bot, ctx.dialogId, messageId);
179
+ } catch (error) {
180
+ this.logger.debug('Failed to mark message as read', {
181
+ dialogId: ctx.dialogId,
182
+ messageId,
183
+ error: serializeError(error),
184
+ });
101
185
  }
102
186
  }
103
187
 
@@ -115,19 +199,14 @@ export class SendService {
115
199
  : text;
116
200
 
117
201
  try {
118
- if (ctx.webhookUrl) {
119
- return await this.api.updateMessage(ctx.webhookUrl, messageId, convertedText);
120
- } else if (ctx.clientEndpoint && ctx.botToken) {
121
- return await this.api.updateMessageWithToken(
122
- ctx.clientEndpoint,
123
- ctx.botToken,
124
- messageId,
125
- convertedText,
126
- );
127
- }
128
- return false;
202
+ return await this.api.updateMessage(
203
+ ctx.webhookUrl,
204
+ ctx.bot,
205
+ messageId,
206
+ convertedText,
207
+ );
129
208
  } catch (error) {
130
- this.logger.error('Failed to update message', error);
209
+ this.logger.error('Failed to update message', { error: serializeError(error) });
131
210
  return false;
132
211
  }
133
212
  }
@@ -145,6 +224,7 @@ export class SendService {
145
224
  textIterator: AsyncIterable<string>,
146
225
  ): Promise<SendMessageResult> {
147
226
  const updateIntervalMs = config.updateIntervalMs ?? 10000;
227
+ await this.sendStatus(ctx, 'IMBOT_AGENT_ACTION_GENERATING');
148
228
 
149
229
  // Step 1: Send initial placeholder
150
230
  const result = await this.sendText(ctx, '...', { convertMarkdown: false });