@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.
- package/README.md +118 -164
- package/index.ts +46 -11
- package/openclaw.plugin.json +1 -0
- package/package.json +1 -1
- package/skills/bitrix24/SKILL.md +70 -0
- package/src/access-control.ts +102 -48
- package/src/api.ts +434 -232
- package/src/channel.ts +1441 -365
- package/src/commands.ts +169 -31
- package/src/config-schema.ts +8 -3
- package/src/config.ts +11 -0
- package/src/dedup.ts +4 -0
- package/src/i18n.ts +127 -0
- package/src/inbound-handler.ts +306 -110
- package/src/media-service.ts +218 -65
- package/src/message-utils.ts +252 -10
- package/src/polling-service.ts +240 -0
- package/src/rate-limiter.ts +11 -6
- package/src/send-service.ts +140 -60
- package/src/types.ts +279 -185
- package/src/utils.ts +54 -3
- package/tests/access-control.test.ts +174 -58
- package/tests/api.test.ts +95 -0
- package/tests/channel.test.ts +230 -9
- package/tests/commands.test.ts +57 -0
- package/tests/config.test.ts +5 -1
- package/tests/i18n.test.ts +47 -0
- package/tests/inbound-handler.test.ts +554 -69
- package/tests/index.test.ts +94 -0
- package/tests/media-service.test.ts +146 -51
- package/tests/message-utils.test.ts +64 -0
- package/tests/polling-service.test.ts +77 -0
- package/tests/rate-limiter.test.ts +2 -2
- package/tests/send-service.test.ts +145 -0
|
@@ -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
|
+
}
|
package/src/rate-limiter.ts
CHANGED
|
@@ -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
|
-
|
|
72
|
-
|
|
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
|
}
|
package/src/send-service.ts
CHANGED
|
@@ -1,27 +1,31 @@
|
|
|
1
|
-
import type {
|
|
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
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
? {
|
|
55
|
+
? { keyboard: options.keyboard }
|
|
51
56
|
: undefined;
|
|
52
57
|
|
|
53
58
|
try {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
100
|
-
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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 });
|