@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
package/src/channel.ts
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
|
+
import { listAccountIds, resolveAccount, getConfig } from './config.js';
|
|
3
|
+
import { Bitrix24Api } from './api.js';
|
|
4
|
+
import { SendService } from './send-service.js';
|
|
5
|
+
import { InboundHandler } from './inbound-handler.js';
|
|
6
|
+
import { defaultLogger } from './utils.js';
|
|
7
|
+
import type { B24MsgContext, B24JoinChatEvent } from './types.js';
|
|
8
|
+
|
|
9
|
+
interface Logger {
|
|
10
|
+
info: (...args: unknown[]) => void;
|
|
11
|
+
warn: (...args: unknown[]) => void;
|
|
12
|
+
error: (...args: unknown[]) => void;
|
|
13
|
+
debug: (...args: unknown[]) => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** State held per running gateway instance */
|
|
17
|
+
interface GatewayState {
|
|
18
|
+
api: Bitrix24Api;
|
|
19
|
+
sendService: SendService;
|
|
20
|
+
inboundHandler: InboundHandler;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
let gatewayState: GatewayState | null = null;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Handle an incoming HTTP request on the webhook route.
|
|
27
|
+
* Called by the HTTP route registered in index.ts.
|
|
28
|
+
*/
|
|
29
|
+
export async function handleWebhookRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
30
|
+
// Always respond 200 quickly — B24 retries if it doesn't get a fast response
|
|
31
|
+
if (req.method !== 'POST') {
|
|
32
|
+
res.statusCode = 405;
|
|
33
|
+
res.end('Method Not Allowed');
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (!gatewayState) {
|
|
38
|
+
res.statusCode = 503;
|
|
39
|
+
res.end('Channel not started');
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Read raw body
|
|
44
|
+
const chunks: Buffer[] = [];
|
|
45
|
+
for await (const chunk of req) {
|
|
46
|
+
chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk);
|
|
47
|
+
}
|
|
48
|
+
const body = Buffer.concat(chunks).toString('utf-8');
|
|
49
|
+
|
|
50
|
+
// Respond immediately
|
|
51
|
+
res.statusCode = 200;
|
|
52
|
+
res.setHeader('Content-Type', 'text/plain');
|
|
53
|
+
res.end('ok');
|
|
54
|
+
|
|
55
|
+
// Process in background
|
|
56
|
+
try {
|
|
57
|
+
await gatewayState.inboundHandler.handleWebhook(body);
|
|
58
|
+
} catch (err) {
|
|
59
|
+
defaultLogger.error('Error handling Bitrix24 webhook', err);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The Bitrix24 channel plugin object.
|
|
65
|
+
*
|
|
66
|
+
* Implements the OpenClaw ChannelPlugin interface.
|
|
67
|
+
*/
|
|
68
|
+
export const bitrix24Plugin = {
|
|
69
|
+
id: 'bitrix24',
|
|
70
|
+
|
|
71
|
+
meta: {
|
|
72
|
+
id: 'bitrix24',
|
|
73
|
+
label: 'Bitrix24',
|
|
74
|
+
selectionLabel: 'Bitrix24 (Messenger)',
|
|
75
|
+
docsPath: '/channels/bitrix24',
|
|
76
|
+
docsLabel: 'bitrix24',
|
|
77
|
+
blurb: 'Connect to Bitrix24 Messenger via chat bot REST API.',
|
|
78
|
+
aliases: ['b24', 'bx24'],
|
|
79
|
+
},
|
|
80
|
+
|
|
81
|
+
capabilities: {
|
|
82
|
+
chatTypes: ['direct', 'group'] as const,
|
|
83
|
+
media: false,
|
|
84
|
+
reactions: false,
|
|
85
|
+
threads: false,
|
|
86
|
+
nativeCommands: true,
|
|
87
|
+
},
|
|
88
|
+
|
|
89
|
+
config: {
|
|
90
|
+
listAccountIds: (cfg: Record<string, unknown>) => listAccountIds(cfg),
|
|
91
|
+
resolveAccount: (cfg: Record<string, unknown>, accountId?: string) =>
|
|
92
|
+
resolveAccount(cfg, accountId),
|
|
93
|
+
},
|
|
94
|
+
|
|
95
|
+
security: {
|
|
96
|
+
resolveDmPolicy: (account: { config?: { dmPolicy?: string } }) =>
|
|
97
|
+
account.config?.dmPolicy ?? 'open',
|
|
98
|
+
normalizeAllowFrom: (entry: string) =>
|
|
99
|
+
entry.replace(/^(bitrix24|b24|bx24):/, ''),
|
|
100
|
+
},
|
|
101
|
+
|
|
102
|
+
outbound: {
|
|
103
|
+
deliveryMode: 'direct' as const,
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Send a text message to B24 via the bot.
|
|
107
|
+
* Called by OpenClaw when the agent produces a response.
|
|
108
|
+
*/
|
|
109
|
+
sendText: async (params: {
|
|
110
|
+
text: string;
|
|
111
|
+
context: B24MsgContext;
|
|
112
|
+
account: { config: { webhookUrl?: string; showTyping?: boolean } };
|
|
113
|
+
}) => {
|
|
114
|
+
const { text, context, account } = params;
|
|
115
|
+
|
|
116
|
+
if (!gatewayState) {
|
|
117
|
+
return { ok: false, error: 'Gateway not started', channel: 'bitrix24' };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const { sendService } = gatewayState;
|
|
121
|
+
|
|
122
|
+
const sendCtx = {
|
|
123
|
+
webhookUrl: account.config.webhookUrl,
|
|
124
|
+
clientEndpoint: context.clientEndpoint,
|
|
125
|
+
botToken: context.botToken,
|
|
126
|
+
dialogId: context.chatId,
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
// Send typing indicator
|
|
130
|
+
if (account.config.showTyping !== false) {
|
|
131
|
+
await sendService.sendTyping(sendCtx);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Send the response
|
|
135
|
+
const result = await sendService.sendText(sendCtx, text);
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
ok: result.ok,
|
|
139
|
+
messageId: result.messageId,
|
|
140
|
+
channel: 'bitrix24' as const,
|
|
141
|
+
error: result.error,
|
|
142
|
+
};
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
|
|
146
|
+
gateway: {
|
|
147
|
+
/**
|
|
148
|
+
* Start a channel account. Called by OpenClaw for each configured account.
|
|
149
|
+
*/
|
|
150
|
+
startAccount: async (ctx: {
|
|
151
|
+
cfg: Record<string, unknown>;
|
|
152
|
+
accountId: string;
|
|
153
|
+
account: { config?: Record<string, unknown> };
|
|
154
|
+
runtime: unknown;
|
|
155
|
+
abortSignal: AbortSignal;
|
|
156
|
+
log?: Logger;
|
|
157
|
+
setStatus?: (status: Record<string, unknown>) => void;
|
|
158
|
+
}) => {
|
|
159
|
+
const logger = ctx.log ?? defaultLogger;
|
|
160
|
+
const config = getConfig(ctx.cfg);
|
|
161
|
+
|
|
162
|
+
if (!config.webhookUrl) {
|
|
163
|
+
logger.warn(`[${ctx.accountId}] no webhookUrl configured, skipping`);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
logger.info(`[${ctx.accountId}] starting Bitrix24 channel`);
|
|
168
|
+
|
|
169
|
+
const api = new Bitrix24Api({ logger });
|
|
170
|
+
const sendService = new SendService(api, logger);
|
|
171
|
+
|
|
172
|
+
const inboundHandler = new InboundHandler({
|
|
173
|
+
config,
|
|
174
|
+
logger,
|
|
175
|
+
onJoinChat: async (event: B24JoinChatEvent) => {
|
|
176
|
+
logger.info('Bot joined chat', {
|
|
177
|
+
dialogId: event.data.PARAMS.DIALOG_ID,
|
|
178
|
+
userId: event.data.PARAMS.USER_ID,
|
|
179
|
+
});
|
|
180
|
+
const dialogId = event.data.PARAMS.DIALOG_ID;
|
|
181
|
+
const botEntry = Object.values(event.data.BOT)[0];
|
|
182
|
+
if (botEntry && dialogId) {
|
|
183
|
+
try {
|
|
184
|
+
await api.sendMessageWithToken(
|
|
185
|
+
botEntry.client_endpoint,
|
|
186
|
+
botEntry.access_token,
|
|
187
|
+
dialogId,
|
|
188
|
+
`${config.botName ?? 'OpenClaw'} ready. Send me a message to get started.`,
|
|
189
|
+
);
|
|
190
|
+
} catch (err) {
|
|
191
|
+
logger.error('Failed to send welcome message', err);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
},
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
gatewayState = { api, sendService, inboundHandler };
|
|
198
|
+
|
|
199
|
+
logger.info(`[${ctx.accountId}] Bitrix24 channel started`);
|
|
200
|
+
|
|
201
|
+
// Keep alive until abort signal
|
|
202
|
+
return new Promise<void>((resolve) => {
|
|
203
|
+
ctx.abortSignal.addEventListener('abort', () => {
|
|
204
|
+
logger.info(`[${ctx.accountId}] Bitrix24 channel stopping`);
|
|
205
|
+
inboundHandler.destroy();
|
|
206
|
+
api.destroy();
|
|
207
|
+
gatewayState = null;
|
|
208
|
+
resolve();
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
},
|
|
212
|
+
},
|
|
213
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
const AccountSchema = z.object({
|
|
4
|
+
enabled: z.boolean().optional().default(true),
|
|
5
|
+
webhookUrl: z.string().url().optional(),
|
|
6
|
+
botName: z.string().optional().default('OpenClaw'),
|
|
7
|
+
botCode: z.string().optional().default('openclaw'),
|
|
8
|
+
botAvatar: z.string().optional(),
|
|
9
|
+
callbackPath: z.string().optional().default('/hooks/bitrix24'),
|
|
10
|
+
dmPolicy: z.enum(['open', 'allowlist', 'pairing']).optional().default('open'),
|
|
11
|
+
allowFrom: z.array(z.string()).optional(),
|
|
12
|
+
showTyping: z.boolean().optional().default(true),
|
|
13
|
+
streamUpdates: z.boolean().optional().default(false),
|
|
14
|
+
updateIntervalMs: z.number().int().min(500).optional().default(10000),
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
export const Bitrix24ConfigSchema = AccountSchema.extend({
|
|
18
|
+
accounts: z.record(z.string(), AccountSchema).optional(),
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
export type Bitrix24ConfigParsed = z.infer<typeof Bitrix24ConfigSchema>;
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { Bitrix24PluginConfig, Bitrix24AccountConfig } from './types.js';
|
|
2
|
+
|
|
3
|
+
const DEFAULT_ACCOUNT_ID = 'default';
|
|
4
|
+
|
|
5
|
+
export function getConfig(
|
|
6
|
+
cfg: Record<string, unknown> | undefined,
|
|
7
|
+
accountId?: string,
|
|
8
|
+
): Bitrix24AccountConfig {
|
|
9
|
+
const b24 = (cfg as { channels?: { bitrix24?: Bitrix24PluginConfig } })
|
|
10
|
+
?.channels?.bitrix24;
|
|
11
|
+
if (!b24) return {};
|
|
12
|
+
|
|
13
|
+
if (accountId && accountId !== DEFAULT_ACCOUNT_ID && b24.accounts?.[accountId]) {
|
|
14
|
+
const { accounts: _, ...base } = b24;
|
|
15
|
+
return { ...base, ...b24.accounts[accountId] };
|
|
16
|
+
}
|
|
17
|
+
return b24;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function isConfigured(
|
|
21
|
+
cfg: Record<string, unknown> | undefined,
|
|
22
|
+
accountId?: string,
|
|
23
|
+
): boolean {
|
|
24
|
+
const config = getConfig(cfg, accountId);
|
|
25
|
+
return Boolean(config.webhookUrl);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function listAccountIds(cfg: Record<string, unknown> | undefined): string[] {
|
|
29
|
+
const b24 = (cfg as { channels?: { bitrix24?: Bitrix24PluginConfig } })
|
|
30
|
+
?.channels?.bitrix24;
|
|
31
|
+
if (!b24) return [];
|
|
32
|
+
|
|
33
|
+
const ids: string[] = [];
|
|
34
|
+
if (b24.webhookUrl) ids.push(DEFAULT_ACCOUNT_ID);
|
|
35
|
+
if (b24.accounts) {
|
|
36
|
+
for (const id of Object.keys(b24.accounts)) {
|
|
37
|
+
ids.push(id);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return ids;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function resolveAccount(
|
|
44
|
+
cfg: Record<string, unknown> | undefined,
|
|
45
|
+
accountId?: string | null,
|
|
46
|
+
): {
|
|
47
|
+
accountId: string;
|
|
48
|
+
config: Bitrix24AccountConfig;
|
|
49
|
+
configured: boolean;
|
|
50
|
+
enabled: boolean;
|
|
51
|
+
} {
|
|
52
|
+
const id = accountId || DEFAULT_ACCOUNT_ID;
|
|
53
|
+
const config = getConfig(cfg, id);
|
|
54
|
+
return {
|
|
55
|
+
accountId: id,
|
|
56
|
+
config,
|
|
57
|
+
configured: Boolean(config.webhookUrl),
|
|
58
|
+
enabled: config.enabled !== false,
|
|
59
|
+
};
|
|
60
|
+
}
|
package/src/dedup.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deduplication for incoming webhook events.
|
|
3
|
+
* B24 may retry webhooks if it doesn't receive a timely 200 response.
|
|
4
|
+
*/
|
|
5
|
+
export class Dedup {
|
|
6
|
+
private seen = new Map<string, number>();
|
|
7
|
+
private readonly ttlMs: number;
|
|
8
|
+
private cleanupTimer: ReturnType<typeof setInterval> | null = null;
|
|
9
|
+
|
|
10
|
+
constructor(opts: { ttlMs?: number } = {}) {
|
|
11
|
+
this.ttlMs = opts.ttlMs ?? 5 * 60 * 1000; // 5 minutes default
|
|
12
|
+
this.cleanupTimer = setInterval(() => this.cleanup(), this.ttlMs);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Check if this message ID has been seen before.
|
|
17
|
+
* If not, marks it as seen and returns false.
|
|
18
|
+
* If yes, returns true (duplicate).
|
|
19
|
+
*/
|
|
20
|
+
isDuplicate(messageId: string | number): boolean {
|
|
21
|
+
const key = String(messageId);
|
|
22
|
+
if (this.seen.has(key)) {
|
|
23
|
+
return true;
|
|
24
|
+
}
|
|
25
|
+
this.seen.set(key, Date.now());
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
private cleanup(): void {
|
|
30
|
+
const now = Date.now();
|
|
31
|
+
for (const [key, ts] of this.seen) {
|
|
32
|
+
if (now - ts > this.ttlMs) {
|
|
33
|
+
this.seen.delete(key);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
get size(): number {
|
|
39
|
+
return this.seen.size;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
destroy(): void {
|
|
43
|
+
if (this.cleanupTimer) {
|
|
44
|
+
clearInterval(this.cleanupTimer);
|
|
45
|
+
this.cleanupTimer = null;
|
|
46
|
+
}
|
|
47
|
+
this.seen.clear();
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import qs from 'qs';
|
|
2
|
+
import type {
|
|
3
|
+
B24Event,
|
|
4
|
+
B24MessageEvent,
|
|
5
|
+
B24JoinChatEvent,
|
|
6
|
+
B24CommandEvent,
|
|
7
|
+
B24AppInstallEvent,
|
|
8
|
+
B24BotEntry,
|
|
9
|
+
B24File,
|
|
10
|
+
B24MsgContext,
|
|
11
|
+
B24MediaItem,
|
|
12
|
+
} from './types.js';
|
|
13
|
+
import { Dedup } from './dedup.js';
|
|
14
|
+
import { checkAccess } from './access-control.js';
|
|
15
|
+
import type { Bitrix24AccountConfig } from './types.js';
|
|
16
|
+
import { defaultLogger } from './utils.js';
|
|
17
|
+
|
|
18
|
+
interface Logger {
|
|
19
|
+
info: (...args: unknown[]) => void;
|
|
20
|
+
warn: (...args: unknown[]) => void;
|
|
21
|
+
error: (...args: unknown[]) => void;
|
|
22
|
+
debug: (...args: unknown[]) => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface InboundHandlerOptions {
|
|
26
|
+
config: Bitrix24AccountConfig;
|
|
27
|
+
logger?: Logger;
|
|
28
|
+
onMessage?: (ctx: B24MsgContext) => void | Promise<void>;
|
|
29
|
+
onJoinChat?: (event: B24JoinChatEvent) => void | Promise<void>;
|
|
30
|
+
onCommand?: (event: B24CommandEvent) => void | Promise<void>;
|
|
31
|
+
onAppInstall?: (event: B24AppInstallEvent) => void | Promise<void>;
|
|
32
|
+
onBotDelete?: (event: B24Event) => void | Promise<void>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export class InboundHandler {
|
|
36
|
+
private dedup: Dedup;
|
|
37
|
+
private config: Bitrix24AccountConfig;
|
|
38
|
+
private logger: Logger;
|
|
39
|
+
private onMessage?: (ctx: B24MsgContext) => void | Promise<void>;
|
|
40
|
+
private onJoinChat?: (event: B24JoinChatEvent) => void | Promise<void>;
|
|
41
|
+
private onCommand?: (event: B24CommandEvent) => void | Promise<void>;
|
|
42
|
+
private onAppInstall?: (event: B24AppInstallEvent) => void | Promise<void>;
|
|
43
|
+
private onBotDelete?: (event: B24Event) => void | Promise<void>;
|
|
44
|
+
|
|
45
|
+
constructor(opts: InboundHandlerOptions) {
|
|
46
|
+
this.dedup = new Dedup();
|
|
47
|
+
this.config = opts.config;
|
|
48
|
+
this.logger = opts.logger ?? defaultLogger;
|
|
49
|
+
this.onMessage = opts.onMessage;
|
|
50
|
+
this.onJoinChat = opts.onJoinChat;
|
|
51
|
+
this.onCommand = opts.onCommand;
|
|
52
|
+
this.onAppInstall = opts.onAppInstall;
|
|
53
|
+
this.onBotDelete = opts.onBotDelete;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Handle a raw incoming webhook request body.
|
|
58
|
+
* B24 sends application/x-www-form-urlencoded or JSON.
|
|
59
|
+
*
|
|
60
|
+
* @param rawBody - Either a parsed object or a URL-encoded string
|
|
61
|
+
* @returns true if the event was handled
|
|
62
|
+
*/
|
|
63
|
+
async handleWebhook(rawBody: string | Record<string, unknown>): Promise<boolean> {
|
|
64
|
+
const payload = typeof rawBody === 'string'
|
|
65
|
+
? (qs.parse(rawBody, { depth: 10 }) as Record<string, unknown>)
|
|
66
|
+
: rawBody;
|
|
67
|
+
|
|
68
|
+
const event = payload as unknown as B24Event;
|
|
69
|
+
const eventType = event.event;
|
|
70
|
+
|
|
71
|
+
if (!eventType) {
|
|
72
|
+
this.logger.warn('Received webhook without event type', payload);
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
this.logger.debug(`Received event: ${eventType}`);
|
|
77
|
+
|
|
78
|
+
switch (eventType) {
|
|
79
|
+
case 'ONIMBOTMESSAGEADD':
|
|
80
|
+
return this.handleMessage(event as B24MessageEvent);
|
|
81
|
+
case 'ONIMBOTJOINCHAT':
|
|
82
|
+
await this.onJoinChat?.(event as B24JoinChatEvent);
|
|
83
|
+
return true;
|
|
84
|
+
case 'ONIMCOMMANDADD':
|
|
85
|
+
await this.onCommand?.(event as B24CommandEvent);
|
|
86
|
+
return true;
|
|
87
|
+
case 'ONAPPINSTALL':
|
|
88
|
+
await this.onAppInstall?.(event as B24AppInstallEvent);
|
|
89
|
+
return true;
|
|
90
|
+
case 'ONIMBOTDELETE':
|
|
91
|
+
await this.onBotDelete?.(event);
|
|
92
|
+
return true;
|
|
93
|
+
default:
|
|
94
|
+
this.logger.debug(`Unhandled event type: ${eventType}`);
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Handle ONIMBOTMESSAGEADD: normalize to B24MsgContext and dispatch.
|
|
101
|
+
*/
|
|
102
|
+
private async handleMessage(event: B24MessageEvent): Promise<boolean> {
|
|
103
|
+
const params = event.data.PARAMS;
|
|
104
|
+
const messageId = params.MESSAGE_ID;
|
|
105
|
+
|
|
106
|
+
// Dedup check
|
|
107
|
+
if (this.dedup.isDuplicate(messageId)) {
|
|
108
|
+
this.logger.debug(`Duplicate message ${messageId}, skipping`);
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const senderId = String(params.FROM_USER_ID);
|
|
113
|
+
|
|
114
|
+
// Access control
|
|
115
|
+
if (!checkAccess(senderId, this.config)) {
|
|
116
|
+
this.logger.debug(`Access denied for user ${senderId}`);
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Extract bot entry
|
|
121
|
+
const botEntry = extractBotEntry(event.data.BOT);
|
|
122
|
+
if (!botEntry) {
|
|
123
|
+
this.logger.error('No bot entry found in event');
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Normalize to B24MsgContext
|
|
128
|
+
const ctx = normalizeMessageEvent(event, botEntry);
|
|
129
|
+
|
|
130
|
+
// Dispatch to handler
|
|
131
|
+
await this.onMessage?.(ctx);
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
destroy(): void {
|
|
136
|
+
this.dedup.destroy();
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ─── Normalization helpers ─────────────────────────────────────────────────
|
|
141
|
+
|
|
142
|
+
function extractBotEntry(botMap: Record<string, B24BotEntry>): B24BotEntry | null {
|
|
143
|
+
const entries = Object.values(botMap);
|
|
144
|
+
return entries[0] ?? null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function normalizeMessageEvent(
|
|
148
|
+
event: B24MessageEvent,
|
|
149
|
+
botEntry: B24BotEntry,
|
|
150
|
+
): B24MsgContext {
|
|
151
|
+
const params = event.data.PARAMS;
|
|
152
|
+
const user = event.data.USER;
|
|
153
|
+
|
|
154
|
+
return {
|
|
155
|
+
channel: 'bitrix24',
|
|
156
|
+
senderId: String(user.ID),
|
|
157
|
+
senderName: user.NAME,
|
|
158
|
+
senderFirstName: user.FIRST_NAME,
|
|
159
|
+
chatId: params.DIALOG_ID,
|
|
160
|
+
chatInternalId: String(params.TO_CHAT_ID),
|
|
161
|
+
messageId: String(params.MESSAGE_ID),
|
|
162
|
+
text: params.MESSAGE || '',
|
|
163
|
+
isDm: params.CHAT_TYPE === 'P',
|
|
164
|
+
isGroup: params.CHAT_TYPE !== 'P',
|
|
165
|
+
media: normalizeFiles(params.FILES),
|
|
166
|
+
platform: params.PLATFORM_CONTEXT,
|
|
167
|
+
language: params.LANGUAGE,
|
|
168
|
+
raw: event,
|
|
169
|
+
botToken: botEntry.access_token,
|
|
170
|
+
userToken: event.auth.access_token,
|
|
171
|
+
clientEndpoint: botEntry.client_endpoint,
|
|
172
|
+
botId: botEntry.BOT_ID,
|
|
173
|
+
memberId: event.auth.member_id,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function normalizeFiles(files?: Record<string, B24File>): B24MediaItem[] {
|
|
178
|
+
if (!files) return [];
|
|
179
|
+
|
|
180
|
+
return Object.values(files).map((file) => ({
|
|
181
|
+
id: String(file.id),
|
|
182
|
+
name: file.name,
|
|
183
|
+
extension: file.extension,
|
|
184
|
+
size: file.size,
|
|
185
|
+
type: file.image ? 'image' as const : 'file' as const,
|
|
186
|
+
}));
|
|
187
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import type { KeyboardButton, B24Keyboard } from './types.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Convert Markdown text to Bitrix24 BB-code chat format.
|
|
5
|
+
*
|
|
6
|
+
* Supported conversions:
|
|
7
|
+
* - **bold** / __bold__ → [B]bold[/B]
|
|
8
|
+
* - *italic* / _italic_ → [I]italic[/I]
|
|
9
|
+
* - ~~strikethrough~~ → [S]strikethrough[/S]
|
|
10
|
+
* - `inline code` → [CODE]inline code[/CODE]
|
|
11
|
+
* - ```code block``` → [CODE]code block[/CODE]
|
|
12
|
+
* - [text](url) → [URL=url]text[/URL]
|
|
13
|
+
* - > blockquote → >>blockquote
|
|
14
|
+
*/
|
|
15
|
+
export function markdownToBbCode(md: string): string {
|
|
16
|
+
let result = md;
|
|
17
|
+
|
|
18
|
+
// Code blocks first (to avoid processing markdown inside them)
|
|
19
|
+
result = result.replace(/```[\w]*\n?([\s\S]*?)```/g, '[CODE]$1[/CODE]');
|
|
20
|
+
|
|
21
|
+
// Inline code
|
|
22
|
+
result = result.replace(/`([^`]+)`/g, '[CODE]$1[/CODE]');
|
|
23
|
+
|
|
24
|
+
// Bold: **text** or __text__
|
|
25
|
+
result = result.replace(/\*\*(.+?)\*\*/g, '[B]$1[/B]');
|
|
26
|
+
result = result.replace(/__(.+?)__/g, '[B]$1[/B]');
|
|
27
|
+
|
|
28
|
+
// Italic: *text* or _text_ (but not inside words with underscores)
|
|
29
|
+
result = result.replace(/(?<!\w)\*([^*]+?)\*(?!\w)/g, '[I]$1[/I]');
|
|
30
|
+
result = result.replace(/(?<!\w)_([^_]+?)_(?!\w)/g, '[I]$1[/I]');
|
|
31
|
+
|
|
32
|
+
// Strikethrough: ~~text~~
|
|
33
|
+
result = result.replace(/~~(.+?)~~/g, '[S]$1[/S]');
|
|
34
|
+
|
|
35
|
+
// Links: [text](url)
|
|
36
|
+
result = result.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '[URL=$2]$1[/URL]');
|
|
37
|
+
|
|
38
|
+
// Blockquotes: > text → >>text
|
|
39
|
+
result = result.replace(/^>\s?(.*)$/gm, '>>$1');
|
|
40
|
+
|
|
41
|
+
return result;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Split a long message into chunks respecting B24's 20000 char limit.
|
|
46
|
+
* Tries to split at line boundaries.
|
|
47
|
+
*/
|
|
48
|
+
export function splitMessage(text: string, maxLen: number = 20000): string[] {
|
|
49
|
+
if (text.length <= maxLen) return [text];
|
|
50
|
+
|
|
51
|
+
const chunks: string[] = [];
|
|
52
|
+
let remaining = text;
|
|
53
|
+
|
|
54
|
+
while (remaining.length > 0) {
|
|
55
|
+
if (remaining.length <= maxLen) {
|
|
56
|
+
chunks.push(remaining);
|
|
57
|
+
break;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Find the last newline within the limit
|
|
61
|
+
let splitAt = remaining.lastIndexOf('\n', maxLen);
|
|
62
|
+
if (splitAt <= 0) {
|
|
63
|
+
// No newline found, split at the limit
|
|
64
|
+
splitAt = maxLen;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
chunks.push(remaining.slice(0, splitAt));
|
|
68
|
+
remaining = remaining.slice(splitAt).replace(/^\n/, '');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return chunks;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Build a KEYBOARD array for imbot.message.add.
|
|
76
|
+
*
|
|
77
|
+
* @param rows - Array of button rows. Each row is an array of buttons.
|
|
78
|
+
*/
|
|
79
|
+
export function buildKeyboard(
|
|
80
|
+
rows: Array<
|
|
81
|
+
Array<{
|
|
82
|
+
text: string;
|
|
83
|
+
command?: string;
|
|
84
|
+
commandParams?: string;
|
|
85
|
+
link?: string;
|
|
86
|
+
bgColor?: string;
|
|
87
|
+
textColor?: string;
|
|
88
|
+
fullWidth?: boolean;
|
|
89
|
+
disableAfterClick?: boolean;
|
|
90
|
+
}>
|
|
91
|
+
>,
|
|
92
|
+
): B24Keyboard {
|
|
93
|
+
return rows.map((row) =>
|
|
94
|
+
row.map((btn): KeyboardButton => ({
|
|
95
|
+
TEXT: btn.text,
|
|
96
|
+
...(btn.command ? { COMMAND: btn.command, COMMAND_PARAMS: btn.commandParams ?? '' } : {}),
|
|
97
|
+
...(btn.link ? { LINK: btn.link } : {}),
|
|
98
|
+
BG_COLOR: btn.bgColor ?? '#29619b',
|
|
99
|
+
TEXT_COLOR: btn.textColor ?? '#fff',
|
|
100
|
+
DISPLAY: btn.fullWidth ? 'LINE' : 'BLOCK',
|
|
101
|
+
BLOCK: btn.disableAfterClick ? 'Y' : 'N',
|
|
102
|
+
})),
|
|
103
|
+
);
|
|
104
|
+
}
|