@ihazz/bitrix24 0.1.3
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/LICENSE +21 -0
- package/README.md +206 -0
- package/index.ts +42 -0
- package/openclaw.plugin.json +9 -0
- package/package.json +50 -0
- package/src/access-control.ts +40 -0
- package/src/api.ts +236 -0
- package/src/channel.ts +213 -0
- package/src/config-schema.ts +21 -0
- package/src/config.ts +60 -0
- package/src/dedup.ts +49 -0
- package/src/inbound-handler.ts +187 -0
- package/src/message-utils.ts +104 -0
- package/src/rate-limiter.ts +76 -0
- package/src/runtime.ts +22 -0
- package/src/send-service.ts +173 -0
- package/src/types.ts +297 -0
- package/src/utils.ts +74 -0
- package/tests/access-control.test.ts +67 -0
- package/tests/config.test.ts +86 -0
- package/tests/dedup.test.ts +50 -0
- package/tests/fixtures/onimbotjoinchat.json +48 -0
- package/tests/fixtures/onimbotmessageadd-file.json +86 -0
- package/tests/fixtures/onimbotmessageadd-text.json +59 -0
- package/tests/fixtures/onimcommandadd.json +45 -0
- package/tests/inbound-handler.test.ts +161 -0
- package/tests/message-utils.test.ts +123 -0
- package/tests/rate-limiter.test.ts +52 -0
- package/tsconfig.json +22 -0
- package/vitest.config.ts +9 -0
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Token-bucket rate limiter for Bitrix24 REST API.
|
|
3
|
+
* B24 webhook limit: 2 requests/second.
|
|
4
|
+
*/
|
|
5
|
+
export class RateLimiter {
|
|
6
|
+
private tokens: number;
|
|
7
|
+
private readonly maxTokens: number;
|
|
8
|
+
private readonly refillRate: number;
|
|
9
|
+
private lastRefill: number;
|
|
10
|
+
private queue: Array<() => void> = [];
|
|
11
|
+
private drainTimer: ReturnType<typeof setTimeout> | null = null;
|
|
12
|
+
|
|
13
|
+
constructor(opts: { maxPerSecond?: number } = {}) {
|
|
14
|
+
const maxPerSecond = opts.maxPerSecond ?? 2;
|
|
15
|
+
this.maxTokens = maxPerSecond;
|
|
16
|
+
this.tokens = maxPerSecond;
|
|
17
|
+
this.refillRate = maxPerSecond;
|
|
18
|
+
this.lastRefill = Date.now();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
private refill(): void {
|
|
22
|
+
const now = Date.now();
|
|
23
|
+
const elapsed = (now - this.lastRefill) / 1000;
|
|
24
|
+
this.tokens = Math.min(this.maxTokens, this.tokens + elapsed * this.refillRate);
|
|
25
|
+
this.lastRefill = now;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async acquire(): Promise<void> {
|
|
29
|
+
this.refill();
|
|
30
|
+
|
|
31
|
+
if (this.tokens >= 1) {
|
|
32
|
+
this.tokens -= 1;
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return new Promise<void>((resolve) => {
|
|
37
|
+
this.queue.push(resolve);
|
|
38
|
+
this.scheduleDrain();
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
private scheduleDrain(): void {
|
|
43
|
+
if (this.drainTimer) return;
|
|
44
|
+
|
|
45
|
+
const waitMs = ((1 - this.tokens) / this.refillRate) * 1000;
|
|
46
|
+
this.drainTimer = setTimeout(() => {
|
|
47
|
+
this.drainTimer = null;
|
|
48
|
+
this.refill();
|
|
49
|
+
|
|
50
|
+
while (this.tokens >= 1 && this.queue.length > 0) {
|
|
51
|
+
this.tokens -= 1;
|
|
52
|
+
const next = this.queue.shift();
|
|
53
|
+
next?.();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (this.queue.length > 0) {
|
|
57
|
+
this.scheduleDrain();
|
|
58
|
+
}
|
|
59
|
+
}, Math.ceil(waitMs));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
get pending(): number {
|
|
63
|
+
return this.queue.length;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
destroy(): void {
|
|
67
|
+
if (this.drainTimer) {
|
|
68
|
+
clearTimeout(this.drainTimer);
|
|
69
|
+
this.drainTimer = null;
|
|
70
|
+
}
|
|
71
|
+
for (const resolve of this.queue) {
|
|
72
|
+
resolve();
|
|
73
|
+
}
|
|
74
|
+
this.queue = [];
|
|
75
|
+
}
|
|
76
|
+
}
|
package/src/runtime.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
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;
|
|
7
|
+
};
|
|
8
|
+
[key: string]: unknown;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
let runtime: PluginRuntime | null = null;
|
|
12
|
+
|
|
13
|
+
export function setBitrix24Runtime(next: PluginRuntime): void {
|
|
14
|
+
runtime = next;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function getBitrix24Runtime(): PluginRuntime {
|
|
18
|
+
if (!runtime) {
|
|
19
|
+
throw new Error('Bitrix24 runtime not initialized');
|
|
20
|
+
}
|
|
21
|
+
return runtime;
|
|
22
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import type { Bitrix24AccountConfig, SendMessageResult, B24Keyboard } from './types.js';
|
|
2
|
+
import { Bitrix24Api } from './api.js';
|
|
3
|
+
import { markdownToBbCode, splitMessage } from './message-utils.js';
|
|
4
|
+
import { defaultLogger } from './utils.js';
|
|
5
|
+
|
|
6
|
+
interface Logger {
|
|
7
|
+
info: (...args: unknown[]) => void;
|
|
8
|
+
warn: (...args: unknown[]) => void;
|
|
9
|
+
error: (...args: unknown[]) => void;
|
|
10
|
+
debug: (...args: unknown[]) => void;
|
|
11
|
+
}
|
|
12
|
+
|
|
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;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class SendService {
|
|
23
|
+
private api: Bitrix24Api;
|
|
24
|
+
private logger: Logger;
|
|
25
|
+
|
|
26
|
+
constructor(api: Bitrix24Api, logger?: Logger) {
|
|
27
|
+
this.api = api;
|
|
28
|
+
this.logger = logger ?? defaultLogger;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Send a text message to B24 chat.
|
|
33
|
+
* Handles markdown→bbcode conversion and message splitting.
|
|
34
|
+
*/
|
|
35
|
+
async sendText(
|
|
36
|
+
ctx: SendContext,
|
|
37
|
+
text: string,
|
|
38
|
+
options?: { keyboard?: B24Keyboard; convertMarkdown?: boolean },
|
|
39
|
+
): Promise<SendMessageResult> {
|
|
40
|
+
const convertedText = options?.convertMarkdown !== false
|
|
41
|
+
? markdownToBbCode(text)
|
|
42
|
+
: text;
|
|
43
|
+
|
|
44
|
+
const chunks = splitMessage(convertedText);
|
|
45
|
+
let lastMessageId: number | undefined;
|
|
46
|
+
|
|
47
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
48
|
+
const isLast = i === chunks.length - 1;
|
|
49
|
+
const msgOptions = isLast && options?.keyboard
|
|
50
|
+
? { KEYBOARD: options.keyboard }
|
|
51
|
+
: undefined;
|
|
52
|
+
|
|
53
|
+
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
|
+
}
|
|
72
|
+
} 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
|
+
};
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return { ok: true, messageId: lastMessageId };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Send typing indicator.
|
|
86
|
+
*/
|
|
87
|
+
async sendTyping(ctx: SendContext): Promise<void> {
|
|
88
|
+
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,
|
|
95
|
+
ctx.dialogId,
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
} catch (error) {
|
|
99
|
+
// Typing indicator failure is non-critical
|
|
100
|
+
this.logger.debug('Failed to send typing indicator', error);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Update an existing message (used for streaming).
|
|
106
|
+
*/
|
|
107
|
+
async updateMessage(
|
|
108
|
+
ctx: SendContext,
|
|
109
|
+
messageId: number,
|
|
110
|
+
text: string,
|
|
111
|
+
options?: { convertMarkdown?: boolean },
|
|
112
|
+
): Promise<boolean> {
|
|
113
|
+
const convertedText = options?.convertMarkdown !== false
|
|
114
|
+
? markdownToBbCode(text)
|
|
115
|
+
: text;
|
|
116
|
+
|
|
117
|
+
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;
|
|
129
|
+
} catch (error) {
|
|
130
|
+
this.logger.error('Failed to update message', error);
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Send a message with streaming updates.
|
|
137
|
+
*
|
|
138
|
+
* 1. Sends an initial placeholder message
|
|
139
|
+
* 2. Updates it at throttled intervals as text accumulates
|
|
140
|
+
* 3. Final update with complete text
|
|
141
|
+
*/
|
|
142
|
+
async sendStreaming(
|
|
143
|
+
ctx: SendContext,
|
|
144
|
+
config: Bitrix24AccountConfig,
|
|
145
|
+
textIterator: AsyncIterable<string>,
|
|
146
|
+
): Promise<SendMessageResult> {
|
|
147
|
+
const updateIntervalMs = config.updateIntervalMs ?? 10000;
|
|
148
|
+
|
|
149
|
+
// Step 1: Send initial placeholder
|
|
150
|
+
const result = await this.sendText(ctx, '...', { convertMarkdown: false });
|
|
151
|
+
if (!result.ok || !result.messageId) return result;
|
|
152
|
+
|
|
153
|
+
const messageId = result.messageId;
|
|
154
|
+
let accumulated = '';
|
|
155
|
+
let lastUpdateTime = Date.now();
|
|
156
|
+
|
|
157
|
+
// Step 2: Accumulate text and update periodically
|
|
158
|
+
for await (const chunk of textIterator) {
|
|
159
|
+
accumulated += chunk;
|
|
160
|
+
|
|
161
|
+
const now = Date.now();
|
|
162
|
+
if (now - lastUpdateTime >= updateIntervalMs) {
|
|
163
|
+
await this.updateMessage(ctx, messageId, accumulated + ' ...');
|
|
164
|
+
lastUpdateTime = now;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Step 3: Final update with complete text
|
|
169
|
+
await this.updateMessage(ctx, messageId, accumulated);
|
|
170
|
+
|
|
171
|
+
return { ok: true, messageId };
|
|
172
|
+
}
|
|
173
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
// ─── Bitrix24 Inbound Event Types ────────────────────────────────────────────
|
|
2
|
+
|
|
3
|
+
/** Auth block present in every B24 webhook event */
|
|
4
|
+
export interface B24Auth {
|
|
5
|
+
access_token: string;
|
|
6
|
+
expires: number;
|
|
7
|
+
scope: string;
|
|
8
|
+
domain: string;
|
|
9
|
+
client_endpoint: string;
|
|
10
|
+
member_id: string;
|
|
11
|
+
user_id: number;
|
|
12
|
+
application_token: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Bot entry in data.BOT map — keyed by BOT_ID */
|
|
16
|
+
export interface B24BotEntry {
|
|
17
|
+
access_token: string;
|
|
18
|
+
expires: number;
|
|
19
|
+
scope: string;
|
|
20
|
+
domain: string;
|
|
21
|
+
client_endpoint: string;
|
|
22
|
+
member_id: string;
|
|
23
|
+
user_id: number;
|
|
24
|
+
client_id: string;
|
|
25
|
+
application_token: string;
|
|
26
|
+
AUTH?: B24Auth;
|
|
27
|
+
BOT_ID: number;
|
|
28
|
+
BOT_CODE: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** User info from data.USER */
|
|
32
|
+
export interface B24User {
|
|
33
|
+
ID: number;
|
|
34
|
+
NAME: string;
|
|
35
|
+
FIRST_NAME: string;
|
|
36
|
+
LAST_NAME: string;
|
|
37
|
+
WORK_POSITION?: string;
|
|
38
|
+
GENDER?: string;
|
|
39
|
+
IS_BOT?: string;
|
|
40
|
+
IS_CONNECTOR?: string;
|
|
41
|
+
IS_NETWORK?: string;
|
|
42
|
+
IS_EXTRANET?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** File attached to a message */
|
|
46
|
+
export interface B24File {
|
|
47
|
+
id: number;
|
|
48
|
+
chatId: number;
|
|
49
|
+
date: string;
|
|
50
|
+
type: string;
|
|
51
|
+
name: string;
|
|
52
|
+
extension: string;
|
|
53
|
+
size: number;
|
|
54
|
+
image: number;
|
|
55
|
+
status: string;
|
|
56
|
+
progress: number;
|
|
57
|
+
authorId: number;
|
|
58
|
+
authorName: string;
|
|
59
|
+
urlPreview: string;
|
|
60
|
+
urlShow: string;
|
|
61
|
+
urlDownload: string;
|
|
62
|
+
viewerAttrs?: {
|
|
63
|
+
viewer: string;
|
|
64
|
+
viewerType: string;
|
|
65
|
+
src: string;
|
|
66
|
+
objectId: number;
|
|
67
|
+
title: string;
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** PARAMS block in ONIMBOTMESSAGEADD */
|
|
72
|
+
export interface B24MessageParams {
|
|
73
|
+
MESSAGE: string;
|
|
74
|
+
TEMPLATE_ID?: string;
|
|
75
|
+
MESSAGE_TYPE: string;
|
|
76
|
+
FROM_USER_ID: number;
|
|
77
|
+
DIALOG_ID: string;
|
|
78
|
+
TO_CHAT_ID: number;
|
|
79
|
+
AUTHOR_ID?: number;
|
|
80
|
+
TO_USER_ID: number;
|
|
81
|
+
MESSAGE_ID: number;
|
|
82
|
+
CHAT_TYPE: string;
|
|
83
|
+
LANGUAGE?: string;
|
|
84
|
+
PLATFORM_CONTEXT?: string;
|
|
85
|
+
CHAT_USER_COUNT?: number;
|
|
86
|
+
PARAMS?: {
|
|
87
|
+
FILE_ID?: number[];
|
|
88
|
+
[key: string]: unknown;
|
|
89
|
+
};
|
|
90
|
+
FILES?: Record<string, B24File>;
|
|
91
|
+
[key: string]: unknown;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Full ONIMBOTMESSAGEADD event payload */
|
|
95
|
+
export interface B24MessageEvent {
|
|
96
|
+
event: 'ONIMBOTMESSAGEADD';
|
|
97
|
+
event_handler_id?: number;
|
|
98
|
+
data: {
|
|
99
|
+
BOT: Record<string, B24BotEntry>;
|
|
100
|
+
PARAMS: B24MessageParams;
|
|
101
|
+
USER: B24User;
|
|
102
|
+
};
|
|
103
|
+
ts: number;
|
|
104
|
+
auth: B24Auth;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** ONIMBOTJOINCHAT event payload */
|
|
108
|
+
export interface B24JoinChatEvent {
|
|
109
|
+
event: 'ONIMBOTJOINCHAT';
|
|
110
|
+
event_handler_id?: number;
|
|
111
|
+
data: {
|
|
112
|
+
BOT: Record<string, B24BotEntry>;
|
|
113
|
+
PARAMS: {
|
|
114
|
+
CHAT_TYPE: string;
|
|
115
|
+
MESSAGE_TYPE: string;
|
|
116
|
+
BOT_ID: number;
|
|
117
|
+
USER_ID: number;
|
|
118
|
+
DIALOG_ID: string;
|
|
119
|
+
LANGUAGE?: string;
|
|
120
|
+
[key: string]: unknown;
|
|
121
|
+
};
|
|
122
|
+
USER: B24User;
|
|
123
|
+
};
|
|
124
|
+
ts: number;
|
|
125
|
+
auth: B24Auth;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Command entry in data.COMMAND map — keyed by COMMAND_ID */
|
|
129
|
+
export interface B24CommandEntry {
|
|
130
|
+
access_token: string;
|
|
131
|
+
BOT_ID: number;
|
|
132
|
+
BOT_CODE: string;
|
|
133
|
+
COMMAND: string;
|
|
134
|
+
COMMAND_ID: number;
|
|
135
|
+
COMMAND_PARAMS: string;
|
|
136
|
+
COMMAND_CONTEXT: 'TEXTAREA' | 'KEYBOARD';
|
|
137
|
+
MESSAGE_ID: number;
|
|
138
|
+
[key: string]: unknown;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** ONIMCOMMANDADD event payload */
|
|
142
|
+
export interface B24CommandEvent {
|
|
143
|
+
event: 'ONIMCOMMANDADD';
|
|
144
|
+
event_handler_id?: number;
|
|
145
|
+
data: {
|
|
146
|
+
COMMAND: Record<string, B24CommandEntry>;
|
|
147
|
+
PARAMS: {
|
|
148
|
+
MESSAGE: string;
|
|
149
|
+
FROM_USER_ID: number;
|
|
150
|
+
DIALOG_ID: string;
|
|
151
|
+
TO_CHAT_ID: number;
|
|
152
|
+
MESSAGE_ID: number;
|
|
153
|
+
CHAT_TYPE: string;
|
|
154
|
+
[key: string]: unknown;
|
|
155
|
+
};
|
|
156
|
+
USER: B24User;
|
|
157
|
+
};
|
|
158
|
+
ts: number;
|
|
159
|
+
auth: B24Auth;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** ONAPPINSTALL event payload */
|
|
163
|
+
export interface B24AppInstallEvent {
|
|
164
|
+
event: 'ONAPPINSTALL';
|
|
165
|
+
data: Record<string, unknown>;
|
|
166
|
+
ts: number;
|
|
167
|
+
auth: B24Auth;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** ONIMBOTDELETE event payload */
|
|
171
|
+
export interface B24BotDeleteEvent {
|
|
172
|
+
event: 'ONIMBOTDELETE';
|
|
173
|
+
data: Record<string, unknown>;
|
|
174
|
+
ts: number;
|
|
175
|
+
auth: B24Auth;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Union of all event types */
|
|
179
|
+
export type B24Event =
|
|
180
|
+
| B24MessageEvent
|
|
181
|
+
| B24JoinChatEvent
|
|
182
|
+
| B24CommandEvent
|
|
183
|
+
| B24AppInstallEvent
|
|
184
|
+
| B24BotDeleteEvent;
|
|
185
|
+
|
|
186
|
+
// ─── Bitrix24 API Response Types ─────────────────────────────────────────────
|
|
187
|
+
|
|
188
|
+
export interface B24ApiResult<T = unknown> {
|
|
189
|
+
result: T;
|
|
190
|
+
time?: {
|
|
191
|
+
start: number;
|
|
192
|
+
finish: number;
|
|
193
|
+
duration: number;
|
|
194
|
+
processing: number;
|
|
195
|
+
date_start: string;
|
|
196
|
+
date_finish: string;
|
|
197
|
+
};
|
|
198
|
+
error?: string;
|
|
199
|
+
error_description?: string;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export interface B24ApiError {
|
|
203
|
+
error: string;
|
|
204
|
+
error_description: string;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ─── Outbound Message Types ──────────────────────────────────────────────────
|
|
208
|
+
|
|
209
|
+
export interface KeyboardButton {
|
|
210
|
+
TEXT: string;
|
|
211
|
+
COMMAND?: string;
|
|
212
|
+
COMMAND_PARAMS?: string;
|
|
213
|
+
BG_COLOR?: string;
|
|
214
|
+
TEXT_COLOR?: string;
|
|
215
|
+
DISPLAY?: 'LINE' | 'BLOCK';
|
|
216
|
+
DISABLED?: 'Y' | 'N';
|
|
217
|
+
BLOCK?: 'Y' | 'N';
|
|
218
|
+
LINK?: string;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export type B24Keyboard = KeyboardButton[][];
|
|
222
|
+
|
|
223
|
+
export interface SendMessageOptions {
|
|
224
|
+
ATTACH?: unknown;
|
|
225
|
+
KEYBOARD?: B24Keyboard;
|
|
226
|
+
MENU?: unknown;
|
|
227
|
+
SYSTEM?: 'Y' | 'N';
|
|
228
|
+
URL_PREVIEW?: 'Y' | 'N';
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export interface SendMessageResult {
|
|
232
|
+
ok: boolean;
|
|
233
|
+
messageId?: number;
|
|
234
|
+
error?: string;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ─── Plugin Config Types ─────────────────────────────────────────────────────
|
|
238
|
+
|
|
239
|
+
export interface Bitrix24AccountConfig {
|
|
240
|
+
enabled?: boolean;
|
|
241
|
+
webhookUrl?: string;
|
|
242
|
+
botName?: string;
|
|
243
|
+
botCode?: string;
|
|
244
|
+
botAvatar?: string;
|
|
245
|
+
callbackPath?: string;
|
|
246
|
+
dmPolicy?: 'open' | 'allowlist' | 'pairing';
|
|
247
|
+
allowFrom?: string[];
|
|
248
|
+
showTyping?: boolean;
|
|
249
|
+
streamUpdates?: boolean;
|
|
250
|
+
updateIntervalMs?: number;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export interface Bitrix24PluginConfig extends Bitrix24AccountConfig {
|
|
254
|
+
accounts?: Record<string, Bitrix24AccountConfig>;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// ─── Normalized Message Context ──────────────────────────────────────────────
|
|
258
|
+
|
|
259
|
+
export interface B24MsgContext {
|
|
260
|
+
channel: 'bitrix24';
|
|
261
|
+
senderId: string;
|
|
262
|
+
senderName: string;
|
|
263
|
+
senderFirstName?: string;
|
|
264
|
+
chatId: string;
|
|
265
|
+
chatInternalId: string;
|
|
266
|
+
messageId: string;
|
|
267
|
+
text: string;
|
|
268
|
+
isDm: boolean;
|
|
269
|
+
isGroup: boolean;
|
|
270
|
+
media: B24MediaItem[];
|
|
271
|
+
platform?: string;
|
|
272
|
+
language?: string;
|
|
273
|
+
raw: B24Event;
|
|
274
|
+
botToken: string;
|
|
275
|
+
userToken: string;
|
|
276
|
+
clientEndpoint: string;
|
|
277
|
+
botId: number;
|
|
278
|
+
memberId: string;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export interface B24MediaItem {
|
|
282
|
+
id: string;
|
|
283
|
+
name: string;
|
|
284
|
+
extension: string;
|
|
285
|
+
size: number;
|
|
286
|
+
type: 'image' | 'file';
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// ─── Portal State ────────────────────────────────────────────────────────────
|
|
290
|
+
|
|
291
|
+
export interface PortalState {
|
|
292
|
+
botId: number;
|
|
293
|
+
memberId: string;
|
|
294
|
+
domain: string;
|
|
295
|
+
clientEndpoint: string;
|
|
296
|
+
applicationToken: string;
|
|
297
|
+
}
|
package/src/utils.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mask a token for safe logging: show first 4 and last 4 characters.
|
|
3
|
+
*/
|
|
4
|
+
export function maskToken(token: string): string {
|
|
5
|
+
if (token.length <= 12) return '****';
|
|
6
|
+
return `${token.slice(0, 4)}...${token.slice(-4)}`;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Retry a function with exponential backoff.
|
|
11
|
+
*/
|
|
12
|
+
export async function withRetry<T>(
|
|
13
|
+
fn: () => Promise<T>,
|
|
14
|
+
opts: {
|
|
15
|
+
maxRetries?: number;
|
|
16
|
+
initialDelayMs?: number;
|
|
17
|
+
maxDelayMs?: number;
|
|
18
|
+
shouldRetry?: (error: unknown) => boolean;
|
|
19
|
+
} = {},
|
|
20
|
+
): Promise<T> {
|
|
21
|
+
const maxRetries = opts.maxRetries ?? 3;
|
|
22
|
+
const initialDelayMs = opts.initialDelayMs ?? 500;
|
|
23
|
+
const maxDelayMs = opts.maxDelayMs ?? 10000;
|
|
24
|
+
const shouldRetry = opts.shouldRetry ?? (() => true);
|
|
25
|
+
|
|
26
|
+
let lastError: unknown;
|
|
27
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
28
|
+
try {
|
|
29
|
+
return await fn();
|
|
30
|
+
} catch (error) {
|
|
31
|
+
lastError = error;
|
|
32
|
+
if (attempt >= maxRetries || !shouldRetry(error)) {
|
|
33
|
+
throw error;
|
|
34
|
+
}
|
|
35
|
+
const delay = Math.min(initialDelayMs * Math.pow(2, attempt), maxDelayMs);
|
|
36
|
+
const jitter = delay * (0.5 + Math.random() * 0.5);
|
|
37
|
+
await new Promise((r) => setTimeout(r, jitter));
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
throw lastError;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Check if a B24 API error is a rate limit error that should be retried.
|
|
45
|
+
*/
|
|
46
|
+
export function isRateLimitError(error: unknown): boolean {
|
|
47
|
+
if (error instanceof Bitrix24ApiError) {
|
|
48
|
+
return error.code === 'QUERY_LIMIT_EXCEEDED';
|
|
49
|
+
}
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Custom error for Bitrix24 API errors.
|
|
55
|
+
*/
|
|
56
|
+
export class Bitrix24ApiError extends Error {
|
|
57
|
+
constructor(
|
|
58
|
+
public readonly code: string,
|
|
59
|
+
public readonly description: string,
|
|
60
|
+
) {
|
|
61
|
+
super(`Bitrix24 API error: ${code} — ${description}`);
|
|
62
|
+
this.name = 'Bitrix24ApiError';
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Simple console logger fallback.
|
|
68
|
+
*/
|
|
69
|
+
export const defaultLogger = {
|
|
70
|
+
info: (...args: unknown[]) => console.log('[bitrix24]', ...args),
|
|
71
|
+
warn: (...args: unknown[]) => console.warn('[bitrix24]', ...args),
|
|
72
|
+
error: (...args: unknown[]) => console.error('[bitrix24]', ...args),
|
|
73
|
+
debug: (...args: unknown[]) => console.debug('[bitrix24]', ...args),
|
|
74
|
+
};
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { checkAccess, normalizeAllowEntry } from '../src/access-control.js';
|
|
3
|
+
|
|
4
|
+
describe('normalizeAllowEntry', () => {
|
|
5
|
+
it('strips bitrix24: prefix', () => {
|
|
6
|
+
expect(normalizeAllowEntry('bitrix24:42')).toBe('42');
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it('strips b24: prefix', () => {
|
|
10
|
+
expect(normalizeAllowEntry('b24:100')).toBe('100');
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('strips bx24: prefix', () => {
|
|
14
|
+
expect(normalizeAllowEntry('bx24:7')).toBe('7');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('returns plain ID as-is', () => {
|
|
18
|
+
expect(normalizeAllowEntry('42')).toBe('42');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('trims whitespace', () => {
|
|
22
|
+
expect(normalizeAllowEntry(' b24:42 ')).toBe('42');
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe('checkAccess', () => {
|
|
27
|
+
it('allows everyone in open mode', () => {
|
|
28
|
+
expect(checkAccess('1', { dmPolicy: 'open' })).toBe(true);
|
|
29
|
+
expect(checkAccess('999', { dmPolicy: 'open' })).toBe(true);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('defaults to open when no policy set', () => {
|
|
33
|
+
expect(checkAccess('1', {})).toBe(true);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('allows listed users in allowlist mode', () => {
|
|
37
|
+
const config = { dmPolicy: 'allowlist' as const, allowFrom: ['1', '42'] };
|
|
38
|
+
expect(checkAccess('1', config)).toBe(true);
|
|
39
|
+
expect(checkAccess('42', config)).toBe(true);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('denies unlisted users in allowlist mode', () => {
|
|
43
|
+
const config = { dmPolicy: 'allowlist' as const, allowFrom: ['1', '42'] };
|
|
44
|
+
expect(checkAccess('99', config)).toBe(false);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('handles allowlist with prefixes', () => {
|
|
48
|
+
const config = { dmPolicy: 'allowlist' as const, allowFrom: ['b24:1', 'bitrix24:42'] };
|
|
49
|
+
expect(checkAccess('1', config)).toBe(true);
|
|
50
|
+
expect(checkAccess('42', config)).toBe(true);
|
|
51
|
+
expect(checkAccess('99', config)).toBe(false);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('denies everyone when allowlist is empty', () => {
|
|
55
|
+
const config = { dmPolicy: 'allowlist' as const, allowFrom: [] };
|
|
56
|
+
expect(checkAccess('1', config)).toBe(false);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('denies when allowlist is undefined', () => {
|
|
60
|
+
const config = { dmPolicy: 'allowlist' as const };
|
|
61
|
+
expect(checkAccess('1', config)).toBe(false);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('treats pairing as open (post-MVP)', () => {
|
|
65
|
+
expect(checkAccess('1', { dmPolicy: 'pairing' })).toBe(true);
|
|
66
|
+
});
|
|
67
|
+
});
|