@ihazz/bitrix24 1.0.3 → 1.1.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 +236 -42
- package/package.json +1 -1
- package/src/access-control.ts +31 -8
- package/src/api.ts +76 -7
- package/src/channel.ts +1025 -137
- package/src/commands.ts +7 -7
- package/src/config-schema.ts +24 -1
- package/src/config.ts +4 -2
- package/src/group-access.ts +279 -0
- package/src/history-cache.ts +122 -0
- package/src/i18n.ts +140 -50
- package/src/inbound-handler.ts +88 -6
- package/src/polling-service.ts +4 -0
- package/src/send-service.ts +14 -5
- package/src/types.ts +67 -3
- package/tests/access-control.test.ts +43 -0
- package/tests/api.test.ts +131 -0
- package/tests/channel-flow.test.ts +1692 -0
- package/tests/channel.test.ts +88 -2
- package/tests/config.test.ts +120 -0
- package/tests/group-access.test.ts +340 -0
- package/tests/history-cache.test.ts +117 -0
- package/tests/i18n.test.ts +55 -12
- package/tests/inbound-handler.test.ts +341 -3
- package/tests/polling-service.test.ts +38 -0
- package/tests/send-service.test.ts +17 -0
|
@@ -0,0 +1,1692 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
|
|
3
|
+
const mockState = vi.hoisted(() => {
|
|
4
|
+
return {
|
|
5
|
+
rootCfg: {} as Record<string, unknown>,
|
|
6
|
+
inboundHandlerOptions: null as Record<string, unknown> | null,
|
|
7
|
+
api: {
|
|
8
|
+
listBots: vi.fn(),
|
|
9
|
+
updateBot: vi.fn(),
|
|
10
|
+
registerBot: vi.fn(),
|
|
11
|
+
registerCommand: vi.fn(),
|
|
12
|
+
getMessage: vi.fn(),
|
|
13
|
+
getMessageContext: vi.fn(),
|
|
14
|
+
sendMessage: vi.fn(),
|
|
15
|
+
addReaction: vi.fn(),
|
|
16
|
+
leaveChat: vi.fn(),
|
|
17
|
+
unsubscribeUserEvents: vi.fn(),
|
|
18
|
+
subscribeUserEvents: vi.fn(),
|
|
19
|
+
destroy: vi.fn(),
|
|
20
|
+
},
|
|
21
|
+
sendService: {
|
|
22
|
+
sendText: vi.fn(),
|
|
23
|
+
sendTyping: vi.fn(),
|
|
24
|
+
sendStatus: vi.fn(),
|
|
25
|
+
markRead: vi.fn(),
|
|
26
|
+
answerCommandText: vi.fn(),
|
|
27
|
+
},
|
|
28
|
+
mediaService: {
|
|
29
|
+
downloadMedia: vi.fn(),
|
|
30
|
+
uploadMediaToChat: vi.fn(),
|
|
31
|
+
cleanupDownloadedMedia: vi.fn(),
|
|
32
|
+
},
|
|
33
|
+
inboundHandler: {
|
|
34
|
+
handleWebhook: vi.fn(),
|
|
35
|
+
handleFetchEvent: vi.fn(),
|
|
36
|
+
destroy: vi.fn(),
|
|
37
|
+
},
|
|
38
|
+
pollingService: {
|
|
39
|
+
options: null as Record<string, unknown> | null,
|
|
40
|
+
start: vi.fn(),
|
|
41
|
+
stop: vi.fn(),
|
|
42
|
+
},
|
|
43
|
+
runtime: {
|
|
44
|
+
config: {
|
|
45
|
+
loadConfig: vi.fn(),
|
|
46
|
+
},
|
|
47
|
+
channel: {
|
|
48
|
+
routing: {
|
|
49
|
+
resolveAgentRoute: vi.fn(),
|
|
50
|
+
},
|
|
51
|
+
reply: {
|
|
52
|
+
finalizeInboundContext: vi.fn(),
|
|
53
|
+
dispatchReplyWithBufferedBlockDispatcher: vi.fn(),
|
|
54
|
+
},
|
|
55
|
+
session: {
|
|
56
|
+
recordInboundSession: vi.fn(),
|
|
57
|
+
},
|
|
58
|
+
pairing: {
|
|
59
|
+
readAllowFromStore: vi.fn(),
|
|
60
|
+
upsertPairingRequest: vi.fn(),
|
|
61
|
+
buildPairingReply: vi.fn(),
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
logging: {
|
|
65
|
+
getChildLogger: vi.fn(),
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
vi.mock('../src/api.js', () => {
|
|
72
|
+
class MockBitrix24Api {
|
|
73
|
+
listBots = mockState.api.listBots;
|
|
74
|
+
updateBot = mockState.api.updateBot;
|
|
75
|
+
registerBot = mockState.api.registerBot;
|
|
76
|
+
registerCommand = mockState.api.registerCommand;
|
|
77
|
+
getMessage = mockState.api.getMessage;
|
|
78
|
+
getMessageContext = mockState.api.getMessageContext;
|
|
79
|
+
sendMessage = mockState.api.sendMessage;
|
|
80
|
+
addReaction = mockState.api.addReaction;
|
|
81
|
+
leaveChat = mockState.api.leaveChat;
|
|
82
|
+
unsubscribeUserEvents = mockState.api.unsubscribeUserEvents;
|
|
83
|
+
subscribeUserEvents = mockState.api.subscribeUserEvents;
|
|
84
|
+
destroy = mockState.api.destroy;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
Bitrix24Api: MockBitrix24Api,
|
|
89
|
+
};
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
vi.mock('../src/send-service.js', () => {
|
|
93
|
+
class MockSendService {
|
|
94
|
+
sendText = mockState.sendService.sendText;
|
|
95
|
+
sendTyping = mockState.sendService.sendTyping;
|
|
96
|
+
sendStatus = mockState.sendService.sendStatus;
|
|
97
|
+
markRead = mockState.sendService.markRead;
|
|
98
|
+
answerCommandText = mockState.sendService.answerCommandText;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
SendService: MockSendService,
|
|
103
|
+
};
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
vi.mock('../src/media-service.js', () => {
|
|
107
|
+
class MockMediaService {
|
|
108
|
+
downloadMedia = mockState.mediaService.downloadMedia;
|
|
109
|
+
uploadMediaToChat = mockState.mediaService.uploadMediaToChat;
|
|
110
|
+
cleanupDownloadedMedia = mockState.mediaService.cleanupDownloadedMedia;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
MediaService: MockMediaService,
|
|
115
|
+
};
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
vi.mock('../src/inbound-handler.js', () => {
|
|
119
|
+
class MockInboundHandler {
|
|
120
|
+
constructor(opts: Record<string, unknown>) {
|
|
121
|
+
mockState.inboundHandlerOptions = opts;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
handleWebhook = mockState.inboundHandler.handleWebhook;
|
|
125
|
+
handleFetchEvent = mockState.inboundHandler.handleFetchEvent;
|
|
126
|
+
destroy = mockState.inboundHandler.destroy;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
InboundHandler: MockInboundHandler,
|
|
131
|
+
};
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
vi.mock('../src/polling-service.js', () => {
|
|
135
|
+
class MockPollingService {
|
|
136
|
+
constructor(opts: Record<string, unknown>) {
|
|
137
|
+
mockState.pollingService.options = opts;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
start = mockState.pollingService.start;
|
|
141
|
+
stop = mockState.pollingService.stop;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
PollingService: MockPollingService,
|
|
146
|
+
};
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
vi.mock('../src/runtime.js', () => {
|
|
150
|
+
return {
|
|
151
|
+
getBitrix24Runtime: () => mockState.runtime,
|
|
152
|
+
};
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
import { __setGatewayStateForTests, bitrix24Plugin } from '../src/channel.js';
|
|
156
|
+
import type { B24MsgContext } from '../src/types.js';
|
|
157
|
+
|
|
158
|
+
const silentLogger = {
|
|
159
|
+
info: vi.fn(),
|
|
160
|
+
warn: vi.fn(),
|
|
161
|
+
error: vi.fn(),
|
|
162
|
+
debug: vi.fn(),
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
function createRootConfig(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
|
166
|
+
return {
|
|
167
|
+
channels: {
|
|
168
|
+
bitrix24: {
|
|
169
|
+
webhookUrl: 'https://test.bitrix24.com/rest/1/token/',
|
|
170
|
+
callbackUrl: 'https://example.com/hooks/bitrix24',
|
|
171
|
+
eventMode: 'webhook',
|
|
172
|
+
botName: 'OpenClaw',
|
|
173
|
+
showTyping: false,
|
|
174
|
+
...overrides,
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function createGroupMessage(overrides: Partial<B24MsgContext> = {}): B24MsgContext {
|
|
181
|
+
return {
|
|
182
|
+
channel: 'bitrix24',
|
|
183
|
+
senderId: '42',
|
|
184
|
+
senderName: 'Alice',
|
|
185
|
+
senderFirstName: 'Alice',
|
|
186
|
+
chatId: 'chat520',
|
|
187
|
+
chatInternalId: '520',
|
|
188
|
+
chatName: 'Чат зеленый 17',
|
|
189
|
+
chatType: 'chat',
|
|
190
|
+
messageId: '100',
|
|
191
|
+
text: 'hello',
|
|
192
|
+
isDm: false,
|
|
193
|
+
isGroup: true,
|
|
194
|
+
media: [],
|
|
195
|
+
language: 'ru',
|
|
196
|
+
timestamp: Date.parse('2026-03-19T14:36:03+02:00'),
|
|
197
|
+
raw: { type: 'ONIMBOTV2MESSAGEADD' } as never,
|
|
198
|
+
botId: 6809,
|
|
199
|
+
memberId: '',
|
|
200
|
+
...overrides,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function createDirectMessage(overrides: Partial<B24MsgContext> = {}): B24MsgContext {
|
|
205
|
+
return {
|
|
206
|
+
channel: 'bitrix24',
|
|
207
|
+
senderId: '1',
|
|
208
|
+
senderName: 'Евгений Шеленков',
|
|
209
|
+
senderFirstName: 'Евгений',
|
|
210
|
+
chatId: '1',
|
|
211
|
+
chatInternalId: '40985',
|
|
212
|
+
chatName: 'Евгений Шеленков',
|
|
213
|
+
chatType: 'private',
|
|
214
|
+
messageId: '500',
|
|
215
|
+
text: 'какие чаты ты видишь?',
|
|
216
|
+
isDm: true,
|
|
217
|
+
isGroup: false,
|
|
218
|
+
media: [],
|
|
219
|
+
language: 'ru',
|
|
220
|
+
timestamp: Date.parse('2026-03-19T15:10:00+02:00'),
|
|
221
|
+
raw: { type: 'ONIMBOTV2MESSAGEADD' } as never,
|
|
222
|
+
botId: 6809,
|
|
223
|
+
memberId: '',
|
|
224
|
+
...overrides,
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
describe('channel group flow', () => {
|
|
229
|
+
let controller: AbortController | null = null;
|
|
230
|
+
let startPromise: Promise<void> | undefined;
|
|
231
|
+
|
|
232
|
+
beforeEach(() => {
|
|
233
|
+
vi.clearAllMocks();
|
|
234
|
+
mockState.inboundHandlerOptions = null;
|
|
235
|
+
mockState.rootCfg = {};
|
|
236
|
+
|
|
237
|
+
mockState.api.listBots.mockResolvedValue({
|
|
238
|
+
bots: [{
|
|
239
|
+
id: 6809,
|
|
240
|
+
code: 'openclaw_1',
|
|
241
|
+
language: 'ru',
|
|
242
|
+
}],
|
|
243
|
+
users: [],
|
|
244
|
+
hasNextPage: false,
|
|
245
|
+
});
|
|
246
|
+
mockState.api.updateBot.mockResolvedValue({});
|
|
247
|
+
mockState.api.registerBot.mockResolvedValue({
|
|
248
|
+
bot: { id: 6809, language: 'ru' },
|
|
249
|
+
});
|
|
250
|
+
mockState.api.registerCommand.mockResolvedValue({});
|
|
251
|
+
mockState.api.getMessage.mockResolvedValue({
|
|
252
|
+
message: {
|
|
253
|
+
id: 201,
|
|
254
|
+
chatId: 520,
|
|
255
|
+
authorId: 42,
|
|
256
|
+
date: '2026-03-19T15:00:00+02:00',
|
|
257
|
+
text: 'исходное сообщение из API',
|
|
258
|
+
isSystem: false,
|
|
259
|
+
uuid: '',
|
|
260
|
+
forward: null,
|
|
261
|
+
params: {},
|
|
262
|
+
viewedByOthers: true,
|
|
263
|
+
},
|
|
264
|
+
user: {
|
|
265
|
+
id: 42,
|
|
266
|
+
active: true,
|
|
267
|
+
name: 'Alice',
|
|
268
|
+
firstName: 'Alice',
|
|
269
|
+
lastName: '',
|
|
270
|
+
workPosition: '',
|
|
271
|
+
color: '#ffffff',
|
|
272
|
+
avatar: '',
|
|
273
|
+
gender: 'F',
|
|
274
|
+
birthday: '',
|
|
275
|
+
extranet: false,
|
|
276
|
+
bot: false,
|
|
277
|
+
connector: false,
|
|
278
|
+
externalAuthId: '',
|
|
279
|
+
status: 'online',
|
|
280
|
+
idle: false,
|
|
281
|
+
lastActivityDate: false,
|
|
282
|
+
absent: false,
|
|
283
|
+
departments: [],
|
|
284
|
+
phones: false,
|
|
285
|
+
type: 'employee',
|
|
286
|
+
},
|
|
287
|
+
});
|
|
288
|
+
mockState.api.getMessageContext.mockResolvedValue({
|
|
289
|
+
messages: [],
|
|
290
|
+
users: [],
|
|
291
|
+
hasPrevPage: false,
|
|
292
|
+
hasNextPage: false,
|
|
293
|
+
});
|
|
294
|
+
mockState.api.sendMessage.mockResolvedValue(9001);
|
|
295
|
+
mockState.api.addReaction.mockResolvedValue(true);
|
|
296
|
+
mockState.api.leaveChat.mockResolvedValue({});
|
|
297
|
+
mockState.api.unsubscribeUserEvents.mockResolvedValue({});
|
|
298
|
+
mockState.api.subscribeUserEvents.mockResolvedValue({});
|
|
299
|
+
mockState.api.destroy.mockResolvedValue(undefined);
|
|
300
|
+
|
|
301
|
+
mockState.sendService.sendText.mockResolvedValue({ ok: true, messageId: 5001 });
|
|
302
|
+
mockState.sendService.sendTyping.mockResolvedValue(undefined);
|
|
303
|
+
mockState.sendService.sendStatus.mockResolvedValue(undefined);
|
|
304
|
+
mockState.sendService.markRead.mockResolvedValue(undefined);
|
|
305
|
+
mockState.sendService.answerCommandText.mockResolvedValue(undefined);
|
|
306
|
+
|
|
307
|
+
mockState.mediaService.downloadMedia.mockResolvedValue(null);
|
|
308
|
+
mockState.mediaService.uploadMediaToChat.mockResolvedValue({ ok: true, messageId: 6001 });
|
|
309
|
+
mockState.mediaService.cleanupDownloadedMedia.mockResolvedValue(undefined);
|
|
310
|
+
|
|
311
|
+
mockState.inboundHandler.handleWebhook.mockResolvedValue(true);
|
|
312
|
+
mockState.inboundHandler.handleFetchEvent.mockResolvedValue(true);
|
|
313
|
+
mockState.inboundHandler.destroy.mockResolvedValue(undefined);
|
|
314
|
+
|
|
315
|
+
mockState.pollingService.options = null;
|
|
316
|
+
mockState.pollingService.start.mockResolvedValue(undefined);
|
|
317
|
+
mockState.pollingService.stop.mockResolvedValue(undefined);
|
|
318
|
+
|
|
319
|
+
mockState.runtime.config.loadConfig.mockImplementation(() => mockState.rootCfg);
|
|
320
|
+
mockState.runtime.channel.routing.resolveAgentRoute.mockReturnValue({
|
|
321
|
+
sessionKey: 'route:session',
|
|
322
|
+
agentId: 'agent:test',
|
|
323
|
+
matchedBy: 'default',
|
|
324
|
+
accountId: 'default',
|
|
325
|
+
});
|
|
326
|
+
mockState.runtime.channel.reply.finalizeInboundContext.mockImplementation((ctx) => ctx);
|
|
327
|
+
mockState.runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher.mockResolvedValue(undefined);
|
|
328
|
+
mockState.runtime.channel.session.recordInboundSession.mockResolvedValue(undefined);
|
|
329
|
+
mockState.runtime.channel.pairing.readAllowFromStore.mockResolvedValue([]);
|
|
330
|
+
mockState.runtime.channel.pairing.upsertPairingRequest.mockResolvedValue({ code: 'ABCD1234', created: true });
|
|
331
|
+
mockState.runtime.channel.pairing.buildPairingReply.mockReturnValue('ABCD1234');
|
|
332
|
+
mockState.runtime.logging.getChildLogger.mockReturnValue(silentLogger);
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
afterEach(async () => {
|
|
336
|
+
if (controller && startPromise) {
|
|
337
|
+
controller.abort();
|
|
338
|
+
await startPromise;
|
|
339
|
+
}
|
|
340
|
+
controller = null;
|
|
341
|
+
startPromise = undefined;
|
|
342
|
+
__setGatewayStateForTests(null);
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
async function startWebhookAccount(overrides: Record<string, unknown> = {}): Promise<Record<string, unknown>> {
|
|
346
|
+
controller = new AbortController();
|
|
347
|
+
mockState.rootCfg = createRootConfig(overrides);
|
|
348
|
+
|
|
349
|
+
startPromise = bitrix24Plugin.gateway.startAccount({
|
|
350
|
+
cfg: mockState.rootCfg,
|
|
351
|
+
accountId: 'default',
|
|
352
|
+
account: {
|
|
353
|
+
config: (mockState.rootCfg.channels as { bitrix24: Record<string, unknown> }).bitrix24,
|
|
354
|
+
},
|
|
355
|
+
runtime: mockState.runtime,
|
|
356
|
+
abortSignal: controller.signal,
|
|
357
|
+
log: silentLogger,
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
for (let attempt = 0; attempt < 10; attempt += 1) {
|
|
361
|
+
if (mockState.inboundHandlerOptions) {
|
|
362
|
+
break;
|
|
363
|
+
}
|
|
364
|
+
await Promise.resolve();
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
expect(mockState.inboundHandlerOptions).not.toBeNull();
|
|
368
|
+
|
|
369
|
+
return mockState.inboundHandlerOptions as Record<string, unknown>;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
it('stores unmentioned group messages in history and injects them after a later mention', async () => {
|
|
373
|
+
const inbound = await startWebhookAccount({
|
|
374
|
+
groupPolicy: 'open',
|
|
375
|
+
requireMention: true,
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
const onMessage = inbound.onMessage as (ctx: B24MsgContext) => Promise<void>;
|
|
379
|
+
|
|
380
|
+
await onMessage(createGroupMessage({
|
|
381
|
+
messageId: '101',
|
|
382
|
+
text: 'обсуждаем план',
|
|
383
|
+
wasMentioned: false,
|
|
384
|
+
}));
|
|
385
|
+
|
|
386
|
+
expect(mockState.runtime.channel.reply.finalizeInboundContext).not.toHaveBeenCalled();
|
|
387
|
+
expect(mockState.runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
|
|
388
|
+
expect(mockState.sendService.sendText).not.toHaveBeenCalled();
|
|
389
|
+
expect(mockState.sendService.markRead).toHaveBeenCalledTimes(1);
|
|
390
|
+
|
|
391
|
+
await onMessage(createGroupMessage({
|
|
392
|
+
messageId: '102',
|
|
393
|
+
text: '[USER=6809]OpenClaw[/USER] подведи итог',
|
|
394
|
+
wasMentioned: true,
|
|
395
|
+
}));
|
|
396
|
+
|
|
397
|
+
expect(mockState.runtime.channel.reply.finalizeInboundContext).toHaveBeenCalledTimes(1);
|
|
398
|
+
const inboundCtx = mockState.runtime.channel.reply.finalizeInboundContext.mock.calls[0][0] as Record<string, unknown>;
|
|
399
|
+
|
|
400
|
+
expect(String(inboundCtx.Body)).toContain('[Chat messages since your last reply - for context]');
|
|
401
|
+
expect(String(inboundCtx.Body)).toContain('обсуждаем план');
|
|
402
|
+
expect(String(inboundCtx.BodyForAgent)).toContain('[USER=6809]OpenClaw[/USER] подведи итог');
|
|
403
|
+
expect(inboundCtx.InboundHistory).toEqual([
|
|
404
|
+
{
|
|
405
|
+
sender: 'Alice',
|
|
406
|
+
body: 'обсуждаем план',
|
|
407
|
+
timestamp: Date.parse('2026-03-19T14:36:03+02:00'),
|
|
408
|
+
},
|
|
409
|
+
]);
|
|
410
|
+
expect(inboundCtx.WasMentioned).toBe(true);
|
|
411
|
+
expect(mockState.runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1);
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
it('subscribes to user events and enables combined polling in fetch mode when agent mode is on', async () => {
|
|
415
|
+
await startWebhookAccount({
|
|
416
|
+
callbackUrl: undefined,
|
|
417
|
+
eventMode: 'fetch',
|
|
418
|
+
agentMode: true,
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
expect(mockState.api.subscribeUserEvents).toHaveBeenCalledTimes(1);
|
|
422
|
+
expect(mockState.api.subscribeUserEvents).toHaveBeenCalledWith(
|
|
423
|
+
'https://test.bitrix24.com/rest/1/token/',
|
|
424
|
+
);
|
|
425
|
+
expect(mockState.api.unsubscribeUserEvents).not.toHaveBeenCalled();
|
|
426
|
+
expect(mockState.pollingService.options).toEqual(expect.objectContaining({
|
|
427
|
+
withUserEvents: true,
|
|
428
|
+
}));
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
it('answers direct native help commands via command answer transport', async () => {
|
|
432
|
+
const inbound = await startWebhookAccount({
|
|
433
|
+
groupPolicy: 'webhookUser',
|
|
434
|
+
requireMention: true,
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
const onCommand = inbound.onCommand as (ctx: Record<string, unknown>) => Promise<void>;
|
|
438
|
+
|
|
439
|
+
await onCommand({
|
|
440
|
+
commandId: 54,
|
|
441
|
+
commandName: 'help',
|
|
442
|
+
commandParams: '',
|
|
443
|
+
commandText: '/help',
|
|
444
|
+
senderId: '1',
|
|
445
|
+
dialogId: '1',
|
|
446
|
+
chatId: '40985',
|
|
447
|
+
chatType: 'P',
|
|
448
|
+
messageId: '490667',
|
|
449
|
+
language: 'ru',
|
|
450
|
+
fetchCtx: {
|
|
451
|
+
webhookUrl: 'https://test.bitrix24.com/rest/1/token/',
|
|
452
|
+
botId: 6809,
|
|
453
|
+
botToken: 'token',
|
|
454
|
+
},
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
expect(mockState.sendService.answerCommandText).toHaveBeenCalledTimes(1);
|
|
458
|
+
expect(mockState.sendService.sendText).not.toHaveBeenCalled();
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
it('unsubscribes from user events in fetch mode when agent mode is off', async () => {
|
|
462
|
+
await startWebhookAccount({
|
|
463
|
+
callbackUrl: undefined,
|
|
464
|
+
eventMode: 'fetch',
|
|
465
|
+
agentMode: false,
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
expect(mockState.api.unsubscribeUserEvents).toHaveBeenCalledTimes(1);
|
|
469
|
+
expect(mockState.api.unsubscribeUserEvents).toHaveBeenCalledWith(
|
|
470
|
+
'https://test.bitrix24.com/rest/1/token/',
|
|
471
|
+
);
|
|
472
|
+
expect(mockState.api.subscribeUserEvents).not.toHaveBeenCalled();
|
|
473
|
+
expect(mockState.pollingService.options).toEqual(expect.objectContaining({
|
|
474
|
+
withUserEvents: false,
|
|
475
|
+
}));
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
it('responds to any group messages from any participants in open mode without mention', async () => {
|
|
479
|
+
const inbound = await startWebhookAccount({
|
|
480
|
+
groupPolicy: 'open',
|
|
481
|
+
requireMention: false,
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
const onMessage = inbound.onMessage as (ctx: B24MsgContext) => Promise<void>;
|
|
485
|
+
|
|
486
|
+
await onMessage(createGroupMessage({
|
|
487
|
+
messageId: '120',
|
|
488
|
+
text: 'первое сообщение от Алисы',
|
|
489
|
+
senderId: '42',
|
|
490
|
+
senderName: 'Alice',
|
|
491
|
+
wasMentioned: false,
|
|
492
|
+
}));
|
|
493
|
+
|
|
494
|
+
await onMessage(createGroupMessage({
|
|
495
|
+
messageId: '121',
|
|
496
|
+
text: 'второе сообщение от Боба',
|
|
497
|
+
senderId: '77',
|
|
498
|
+
senderName: 'Bob',
|
|
499
|
+
wasMentioned: false,
|
|
500
|
+
timestamp: Date.parse('2026-03-19T14:36:33+02:00'),
|
|
501
|
+
}));
|
|
502
|
+
|
|
503
|
+
expect(mockState.sendService.sendText).not.toHaveBeenCalled();
|
|
504
|
+
expect(mockState.api.addReaction).not.toHaveBeenCalled();
|
|
505
|
+
expect(mockState.runtime.channel.reply.finalizeInboundContext).toHaveBeenCalledTimes(2);
|
|
506
|
+
expect(mockState.runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(2);
|
|
507
|
+
|
|
508
|
+
const secondInboundCtx = mockState.runtime.channel.reply.finalizeInboundContext.mock.calls[1][0] as Record<string, unknown>;
|
|
509
|
+
expect(secondInboundCtx.ChatType).toBe('group');
|
|
510
|
+
expect(secondInboundCtx.WasMentioned).toBe(false);
|
|
511
|
+
expect(secondInboundCtx.InboundHistory).toEqual([
|
|
512
|
+
{
|
|
513
|
+
sender: 'Alice',
|
|
514
|
+
body: 'первое сообщение от Алисы',
|
|
515
|
+
timestamp: Date.parse('2026-03-19T14:36:03+02:00'),
|
|
516
|
+
},
|
|
517
|
+
]);
|
|
518
|
+
expect(String(secondInboundCtx.Body)).toContain('первое сообщение от Алисы');
|
|
519
|
+
expect(String(secondInboundCtx.Body)).toContain('второе сообщение от Боба');
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
it('captures all group messages and answers only mentioned messages in open mode', async () => {
|
|
523
|
+
const inbound = await startWebhookAccount({
|
|
524
|
+
groupPolicy: 'open',
|
|
525
|
+
requireMention: true,
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
const onMessage = inbound.onMessage as (ctx: B24MsgContext) => Promise<void>;
|
|
529
|
+
|
|
530
|
+
await onMessage(createGroupMessage({
|
|
531
|
+
messageId: '130',
|
|
532
|
+
text: 'обычное сообщение без упоминания',
|
|
533
|
+
senderId: '42',
|
|
534
|
+
senderName: 'Alice',
|
|
535
|
+
wasMentioned: false,
|
|
536
|
+
}));
|
|
537
|
+
|
|
538
|
+
expect(mockState.sendService.markRead).toHaveBeenCalledTimes(1);
|
|
539
|
+
expect(mockState.runtime.channel.reply.finalizeInboundContext).not.toHaveBeenCalled();
|
|
540
|
+
|
|
541
|
+
await onMessage(createGroupMessage({
|
|
542
|
+
messageId: '131',
|
|
543
|
+
text: '[USER=6809]OpenClaw[/USER] ответь мне',
|
|
544
|
+
senderId: '77',
|
|
545
|
+
senderName: 'Bob',
|
|
546
|
+
wasMentioned: true,
|
|
547
|
+
}));
|
|
548
|
+
|
|
549
|
+
expect(mockState.sendService.markRead).toHaveBeenCalledTimes(2);
|
|
550
|
+
expect(mockState.runtime.channel.reply.finalizeInboundContext).toHaveBeenCalledTimes(1);
|
|
551
|
+
const inboundCtx = mockState.runtime.channel.reply.finalizeInboundContext.mock.calls[0][0] as Record<string, unknown>;
|
|
552
|
+
expect(inboundCtx.InboundHistory).toEqual([
|
|
553
|
+
{
|
|
554
|
+
sender: 'Alice',
|
|
555
|
+
body: 'обычное сообщение без упоминания',
|
|
556
|
+
timestamp: Date.parse('2026-03-19T14:36:03+02:00'),
|
|
557
|
+
},
|
|
558
|
+
]);
|
|
559
|
+
expect(String(inboundCtx.BodyForAgent)).toContain('ответь мне');
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
it('sends an owner-only notice when a non-owner is denied in webhookUser group mode', async () => {
|
|
563
|
+
const inbound = await startWebhookAccount({
|
|
564
|
+
groupPolicy: 'webhookUser',
|
|
565
|
+
requireMention: true,
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
const onMessage = inbound.onMessage as (ctx: B24MsgContext) => Promise<void>;
|
|
569
|
+
|
|
570
|
+
await onMessage(createGroupMessage({
|
|
571
|
+
messageId: '150',
|
|
572
|
+
text: '[USER=6809]OpenClaw[/USER] а мне поможешь?',
|
|
573
|
+
senderId: '77',
|
|
574
|
+
senderName: 'Colleague',
|
|
575
|
+
wasMentioned: true,
|
|
576
|
+
}));
|
|
577
|
+
|
|
578
|
+
expect(mockState.sendService.markRead).toHaveBeenCalledTimes(1);
|
|
579
|
+
expect(mockState.api.addReaction).toHaveBeenCalledTimes(1);
|
|
580
|
+
expect(mockState.api.addReaction).toHaveBeenCalledWith(
|
|
581
|
+
'https://test.bitrix24.com/rest/1/token/',
|
|
582
|
+
{ botId: 6809, botToken: expect.any(String) },
|
|
583
|
+
150,
|
|
584
|
+
'crossMark',
|
|
585
|
+
);
|
|
586
|
+
expect(mockState.sendService.sendText).toHaveBeenCalledTimes(1);
|
|
587
|
+
expect(mockState.sendService.sendText).toHaveBeenCalledWith(
|
|
588
|
+
expect.objectContaining({
|
|
589
|
+
dialogId: 'chat520',
|
|
590
|
+
}),
|
|
591
|
+
'Это персональный бот. Писать ему может только владелец.',
|
|
592
|
+
{ convertMarkdown: false },
|
|
593
|
+
);
|
|
594
|
+
expect(mockState.runtime.channel.reply.finalizeInboundContext).not.toHaveBeenCalled();
|
|
595
|
+
expect(mockState.runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
it('keeps unmentioned denied group messages in RAM history without sending a notice', async () => {
|
|
599
|
+
const inbound = await startWebhookAccount({
|
|
600
|
+
groupPolicy: 'webhookUser',
|
|
601
|
+
requireMention: true,
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
const onMessage = inbound.onMessage as (ctx: B24MsgContext) => Promise<void>;
|
|
605
|
+
|
|
606
|
+
await onMessage(createGroupMessage({
|
|
607
|
+
messageId: '155',
|
|
608
|
+
text: 'секретный секрет',
|
|
609
|
+
senderId: '77',
|
|
610
|
+
senderName: 'Сергей Рыжиков',
|
|
611
|
+
wasMentioned: false,
|
|
612
|
+
}));
|
|
613
|
+
|
|
614
|
+
expect(mockState.sendService.markRead).toHaveBeenCalledTimes(1);
|
|
615
|
+
expect(mockState.api.addReaction).not.toHaveBeenCalled();
|
|
616
|
+
expect(mockState.sendService.sendText).not.toHaveBeenCalled();
|
|
617
|
+
expect(mockState.runtime.channel.reply.finalizeInboundContext).not.toHaveBeenCalled();
|
|
618
|
+
expect(mockState.runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
|
|
619
|
+
|
|
620
|
+
await onMessage(createGroupMessage({
|
|
621
|
+
messageId: '156',
|
|
622
|
+
text: '[USER=6809]OpenClaw[/USER] что написал Сергей Рыжиков в этом чате?',
|
|
623
|
+
senderId: '1',
|
|
624
|
+
senderName: 'Евгений Шеленков',
|
|
625
|
+
wasMentioned: true,
|
|
626
|
+
timestamp: Date.parse('2026-03-19T14:37:03+02:00'),
|
|
627
|
+
}));
|
|
628
|
+
|
|
629
|
+
const inboundCtx = mockState.runtime.channel.reply.finalizeInboundContext.mock.calls[0][0] as Record<string, unknown>;
|
|
630
|
+
expect(inboundCtx.InboundHistory).toEqual([
|
|
631
|
+
{
|
|
632
|
+
sender: 'Сергей Рыжиков',
|
|
633
|
+
body: 'секретный секрет',
|
|
634
|
+
timestamp: Date.parse('2026-03-19T14:36:03+02:00'),
|
|
635
|
+
},
|
|
636
|
+
]);
|
|
637
|
+
expect(String(inboundCtx.Body)).toContain('секретный секрет');
|
|
638
|
+
expect(mockState.runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1);
|
|
639
|
+
});
|
|
640
|
+
|
|
641
|
+
it('responds to all owner group messages without mention in webhookUser mode', async () => {
|
|
642
|
+
const inbound = await startWebhookAccount({
|
|
643
|
+
groupPolicy: 'webhookUser',
|
|
644
|
+
requireMention: false,
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
const onMessage = inbound.onMessage as (ctx: B24MsgContext) => Promise<void>;
|
|
648
|
+
|
|
649
|
+
await onMessage(createGroupMessage({
|
|
650
|
+
messageId: '157',
|
|
651
|
+
text: 'мой вопрос без упоминания',
|
|
652
|
+
senderId: '1',
|
|
653
|
+
senderName: 'Евгений Шеленков',
|
|
654
|
+
wasMentioned: false,
|
|
655
|
+
}));
|
|
656
|
+
|
|
657
|
+
expect(mockState.sendService.sendText).not.toHaveBeenCalled();
|
|
658
|
+
expect(mockState.api.addReaction).not.toHaveBeenCalled();
|
|
659
|
+
expect(mockState.runtime.channel.reply.finalizeInboundContext).toHaveBeenCalledTimes(1);
|
|
660
|
+
expect(mockState.runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1);
|
|
661
|
+
|
|
662
|
+
const inboundCtx = mockState.runtime.channel.reply.finalizeInboundContext.mock.calls[0][0] as Record<string, unknown>;
|
|
663
|
+
expect(inboundCtx.ChatType).toBe('group');
|
|
664
|
+
expect(inboundCtx.WasMentioned).toBe(false);
|
|
665
|
+
expect(String(inboundCtx.BodyForAgent)).toContain('мой вопрос без упоминания');
|
|
666
|
+
expect(String(inboundCtx.From)).toBe('bitrix24:chat520');
|
|
667
|
+
});
|
|
668
|
+
|
|
669
|
+
it('captures all group messages and silently ignores non-owner messages without mention in webhookUser mode', async () => {
|
|
670
|
+
const inbound = await startWebhookAccount({
|
|
671
|
+
groupPolicy: 'webhookUser',
|
|
672
|
+
requireMention: false,
|
|
673
|
+
});
|
|
674
|
+
|
|
675
|
+
const onMessage = inbound.onMessage as (ctx: B24MsgContext) => Promise<void>;
|
|
676
|
+
|
|
677
|
+
await onMessage(createGroupMessage({
|
|
678
|
+
messageId: '158',
|
|
679
|
+
text: 'чужое сообщение',
|
|
680
|
+
senderId: '77',
|
|
681
|
+
senderName: 'Сергей Рыжиков',
|
|
682
|
+
wasMentioned: false,
|
|
683
|
+
}));
|
|
684
|
+
|
|
685
|
+
expect(mockState.sendService.markRead).toHaveBeenCalledTimes(1);
|
|
686
|
+
expect(mockState.sendService.sendText).not.toHaveBeenCalled();
|
|
687
|
+
expect(mockState.api.addReaction).not.toHaveBeenCalled();
|
|
688
|
+
expect(mockState.runtime.channel.reply.finalizeInboundContext).not.toHaveBeenCalled();
|
|
689
|
+
|
|
690
|
+
await onMessage(createGroupMessage({
|
|
691
|
+
messageId: '159',
|
|
692
|
+
text: 'мой вопрос в группе',
|
|
693
|
+
senderId: '1',
|
|
694
|
+
senderName: 'Евгений Шеленков',
|
|
695
|
+
wasMentioned: false,
|
|
696
|
+
}));
|
|
697
|
+
|
|
698
|
+
expect(mockState.sendService.markRead).toHaveBeenCalledTimes(2);
|
|
699
|
+
expect(mockState.runtime.channel.reply.finalizeInboundContext).toHaveBeenCalledTimes(1);
|
|
700
|
+
const inboundCtx = mockState.runtime.channel.reply.finalizeInboundContext.mock.calls[0][0] as Record<string, unknown>;
|
|
701
|
+
expect(inboundCtx.InboundHistory).toEqual([
|
|
702
|
+
{
|
|
703
|
+
sender: 'Сергей Рыжиков',
|
|
704
|
+
body: 'чужое сообщение',
|
|
705
|
+
timestamp: Date.parse('2026-03-19T14:36:03+02:00'),
|
|
706
|
+
},
|
|
707
|
+
]);
|
|
708
|
+
expect(String(inboundCtx.BodyForAgent)).toContain('мой вопрос в группе');
|
|
709
|
+
expect(inboundCtx.WasMentioned).toBe(false);
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
it('keeps denied group messages in RAM history for the owner context', async () => {
|
|
713
|
+
const inbound = await startWebhookAccount({
|
|
714
|
+
groupPolicy: 'webhookUser',
|
|
715
|
+
requireMention: true,
|
|
716
|
+
});
|
|
717
|
+
|
|
718
|
+
const onMessage = inbound.onMessage as (ctx: B24MsgContext) => Promise<void>;
|
|
719
|
+
|
|
720
|
+
await onMessage(createGroupMessage({
|
|
721
|
+
messageId: '160',
|
|
722
|
+
text: '[USER=6809]OpenClaw[/USER] а мне поможешь?',
|
|
723
|
+
senderId: '77',
|
|
724
|
+
senderName: 'Сергей Рыжиков',
|
|
725
|
+
wasMentioned: true,
|
|
726
|
+
}));
|
|
727
|
+
|
|
728
|
+
mockState.runtime.channel.reply.finalizeInboundContext.mockClear();
|
|
729
|
+
mockState.runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher.mockClear();
|
|
730
|
+
mockState.sendService.sendText.mockClear();
|
|
731
|
+
|
|
732
|
+
await onMessage(createGroupMessage({
|
|
733
|
+
messageId: '161',
|
|
734
|
+
text: '[USER=6809]OpenClaw[/USER] что написал Сергей Рыжиков?',
|
|
735
|
+
senderId: '1',
|
|
736
|
+
senderName: 'Евгений Шеленков',
|
|
737
|
+
wasMentioned: true,
|
|
738
|
+
}));
|
|
739
|
+
|
|
740
|
+
const inboundCtx = mockState.runtime.channel.reply.finalizeInboundContext.mock.calls[0][0] as Record<string, unknown>;
|
|
741
|
+
expect(inboundCtx.InboundHistory).toEqual([
|
|
742
|
+
{
|
|
743
|
+
sender: 'Сергей Рыжиков',
|
|
744
|
+
body: '[USER=6809]OpenClaw[/USER] а мне поможешь?',
|
|
745
|
+
timestamp: Date.parse('2026-03-19T14:36:03+02:00'),
|
|
746
|
+
},
|
|
747
|
+
]);
|
|
748
|
+
expect(String(inboundCtx.Body)).toContain('Сергей Рыжиков');
|
|
749
|
+
expect(String(inboundCtx.Body)).toContain('а мне поможешь?');
|
|
750
|
+
expect(mockState.runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1);
|
|
751
|
+
});
|
|
752
|
+
|
|
753
|
+
it('captures all group messages and answers only owner mentions in webhookUser mode', async () => {
|
|
754
|
+
const inbound = await startWebhookAccount({
|
|
755
|
+
groupPolicy: 'webhookUser',
|
|
756
|
+
requireMention: true,
|
|
757
|
+
});
|
|
758
|
+
|
|
759
|
+
const onMessage = inbound.onMessage as (ctx: B24MsgContext) => Promise<void>;
|
|
760
|
+
|
|
761
|
+
await onMessage(createGroupMessage({
|
|
762
|
+
messageId: '162',
|
|
763
|
+
text: 'мой текст без упоминания',
|
|
764
|
+
senderId: '1',
|
|
765
|
+
senderName: 'Евгений Шеленков',
|
|
766
|
+
wasMentioned: false,
|
|
767
|
+
}));
|
|
768
|
+
await onMessage(createGroupMessage({
|
|
769
|
+
messageId: '163',
|
|
770
|
+
text: '[USER=6809]OpenClaw[/USER] а мне можно?',
|
|
771
|
+
senderId: '77',
|
|
772
|
+
senderName: 'Сергей Рыжиков',
|
|
773
|
+
wasMentioned: true,
|
|
774
|
+
}));
|
|
775
|
+
|
|
776
|
+
expect(mockState.sendService.markRead).toHaveBeenCalledTimes(2);
|
|
777
|
+
expect(mockState.sendService.sendText).toHaveBeenCalledTimes(1);
|
|
778
|
+
expect(mockState.api.addReaction).toHaveBeenCalledTimes(1);
|
|
779
|
+
expect(mockState.runtime.channel.reply.finalizeInboundContext).not.toHaveBeenCalled();
|
|
780
|
+
|
|
781
|
+
await onMessage(createGroupMessage({
|
|
782
|
+
messageId: '164',
|
|
783
|
+
text: '[USER=6809]OpenClaw[/USER] только мне ответь',
|
|
784
|
+
senderId: '1',
|
|
785
|
+
senderName: 'Евгений Шеленков',
|
|
786
|
+
wasMentioned: true,
|
|
787
|
+
}));
|
|
788
|
+
|
|
789
|
+
expect(mockState.sendService.markRead).toHaveBeenCalledTimes(3);
|
|
790
|
+
expect(mockState.runtime.channel.reply.finalizeInboundContext).toHaveBeenCalledTimes(1);
|
|
791
|
+
const inboundCtx = mockState.runtime.channel.reply.finalizeInboundContext.mock.calls[0][0] as Record<string, unknown>;
|
|
792
|
+
expect(inboundCtx.InboundHistory).toEqual([
|
|
793
|
+
{
|
|
794
|
+
sender: 'Евгений Шеленков',
|
|
795
|
+
body: 'мой текст без упоминания',
|
|
796
|
+
timestamp: Date.parse('2026-03-19T14:36:03+02:00'),
|
|
797
|
+
},
|
|
798
|
+
{
|
|
799
|
+
sender: 'Сергей Рыжиков',
|
|
800
|
+
body: '[USER=6809]OpenClaw[/USER] а мне можно?',
|
|
801
|
+
timestamp: Date.parse('2026-03-19T14:36:03+02:00'),
|
|
802
|
+
},
|
|
803
|
+
]);
|
|
804
|
+
expect(String(inboundCtx.BodyForAgent)).toContain('только мне ответь');
|
|
805
|
+
expect(inboundCtx.WasMentioned).toBe(true);
|
|
806
|
+
});
|
|
807
|
+
|
|
808
|
+
it('uses allowlist-specific notice when denied user is outside approved access', async () => {
|
|
809
|
+
const inbound = await startWebhookAccount({
|
|
810
|
+
groupPolicy: 'allowlist',
|
|
811
|
+
allowFrom: ['1'],
|
|
812
|
+
requireMention: true,
|
|
813
|
+
});
|
|
814
|
+
|
|
815
|
+
const onMessage = inbound.onMessage as (ctx: B24MsgContext) => Promise<void>;
|
|
816
|
+
|
|
817
|
+
await onMessage(createGroupMessage({
|
|
818
|
+
messageId: '170',
|
|
819
|
+
text: '[USER=6809]OpenClaw[/USER] а мне можно?',
|
|
820
|
+
senderId: '77',
|
|
821
|
+
senderName: 'Сергей Рыжиков',
|
|
822
|
+
wasMentioned: true,
|
|
823
|
+
}));
|
|
824
|
+
|
|
825
|
+
expect(mockState.sendService.sendText).toHaveBeenCalledTimes(1);
|
|
826
|
+
expect(mockState.sendService.sendText).toHaveBeenCalledWith(
|
|
827
|
+
expect.objectContaining({
|
|
828
|
+
dialogId: 'chat520',
|
|
829
|
+
}),
|
|
830
|
+
'Этот бот доступен только владельцу и пользователям с подтвержденным доступом.',
|
|
831
|
+
{ convertMarkdown: false },
|
|
832
|
+
);
|
|
833
|
+
});
|
|
834
|
+
|
|
835
|
+
it('captures all group messages and answers only allowlisted mentions in allowlist mode', async () => {
|
|
836
|
+
const inbound = await startWebhookAccount({
|
|
837
|
+
groupPolicy: 'allowlist',
|
|
838
|
+
allowFrom: ['77'],
|
|
839
|
+
requireMention: true,
|
|
840
|
+
});
|
|
841
|
+
|
|
842
|
+
const onMessage = inbound.onMessage as (ctx: B24MsgContext) => Promise<void>;
|
|
843
|
+
|
|
844
|
+
await onMessage(createGroupMessage({
|
|
845
|
+
messageId: '171',
|
|
846
|
+
text: 'сообщение без mention от allowlist',
|
|
847
|
+
senderId: '77',
|
|
848
|
+
senderName: 'Сергей Рыжиков',
|
|
849
|
+
wasMentioned: false,
|
|
850
|
+
}));
|
|
851
|
+
await onMessage(createGroupMessage({
|
|
852
|
+
messageId: '172',
|
|
853
|
+
text: '[USER=6809]OpenClaw[/USER] чужой mention',
|
|
854
|
+
senderId: '42',
|
|
855
|
+
senderName: 'Alice',
|
|
856
|
+
wasMentioned: true,
|
|
857
|
+
}));
|
|
858
|
+
|
|
859
|
+
expect(mockState.sendService.markRead).toHaveBeenCalledTimes(2);
|
|
860
|
+
expect(mockState.sendService.sendText).toHaveBeenCalledTimes(1);
|
|
861
|
+
expect(mockState.runtime.channel.reply.finalizeInboundContext).not.toHaveBeenCalled();
|
|
862
|
+
|
|
863
|
+
await onMessage(createGroupMessage({
|
|
864
|
+
messageId: '173',
|
|
865
|
+
text: '[USER=6809]OpenClaw[/USER] allowlist mention',
|
|
866
|
+
senderId: '77',
|
|
867
|
+
senderName: 'Сергей Рыжиков',
|
|
868
|
+
wasMentioned: true,
|
|
869
|
+
}));
|
|
870
|
+
|
|
871
|
+
expect(mockState.sendService.markRead).toHaveBeenCalledTimes(3);
|
|
872
|
+
expect(mockState.runtime.channel.reply.finalizeInboundContext).toHaveBeenCalledTimes(1);
|
|
873
|
+
const inboundCtx = mockState.runtime.channel.reply.finalizeInboundContext.mock.calls[0][0] as Record<string, unknown>;
|
|
874
|
+
expect(inboundCtx.InboundHistory).toEqual([
|
|
875
|
+
{
|
|
876
|
+
sender: 'Сергей Рыжиков',
|
|
877
|
+
body: 'сообщение без mention от allowlist',
|
|
878
|
+
timestamp: Date.parse('2026-03-19T14:36:03+02:00'),
|
|
879
|
+
},
|
|
880
|
+
{
|
|
881
|
+
sender: 'Alice',
|
|
882
|
+
body: '[USER=6809]OpenClaw[/USER] чужой mention',
|
|
883
|
+
timestamp: Date.parse('2026-03-19T14:36:03+02:00'),
|
|
884
|
+
},
|
|
885
|
+
]);
|
|
886
|
+
expect(String(inboundCtx.BodyForAgent)).toContain('allowlist mention');
|
|
887
|
+
});
|
|
888
|
+
|
|
889
|
+
it('hydrates reply context from RAM cache for group replies', async () => {
|
|
890
|
+
const inbound = await startWebhookAccount({
|
|
891
|
+
groupPolicy: 'open',
|
|
892
|
+
requireMention: false,
|
|
893
|
+
});
|
|
894
|
+
|
|
895
|
+
const onMessage = inbound.onMessage as (ctx: B24MsgContext) => Promise<void>;
|
|
896
|
+
|
|
897
|
+
await onMessage(createGroupMessage({
|
|
898
|
+
messageId: '201',
|
|
899
|
+
text: 'исходное сообщение',
|
|
900
|
+
wasMentioned: false,
|
|
901
|
+
}));
|
|
902
|
+
|
|
903
|
+
mockState.runtime.channel.reply.finalizeInboundContext.mockClear();
|
|
904
|
+
mockState.runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher.mockClear();
|
|
905
|
+
|
|
906
|
+
await onMessage(createGroupMessage({
|
|
907
|
+
messageId: '202',
|
|
908
|
+
text: 'ответ на сообщение',
|
|
909
|
+
replyToMessageId: '201',
|
|
910
|
+
senderName: 'Bob',
|
|
911
|
+
senderId: '77',
|
|
912
|
+
wasMentioned: false,
|
|
913
|
+
}));
|
|
914
|
+
|
|
915
|
+
const inboundCtx = mockState.runtime.channel.reply.finalizeInboundContext.mock.calls[0][0] as Record<string, unknown>;
|
|
916
|
+
expect(inboundCtx.ReplyToId).toBe('201');
|
|
917
|
+
expect(inboundCtx.ReplyToBody).toBe('исходное сообщение');
|
|
918
|
+
expect(inboundCtx.ReplyToSender).toBe('Alice');
|
|
919
|
+
expect(String(inboundCtx.Body)).toContain('[Replying to Alice id:201]');
|
|
920
|
+
expect(mockState.runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1);
|
|
921
|
+
});
|
|
922
|
+
|
|
923
|
+
it('hydrates reply context from Bitrix24 API when RAM cache misses', async () => {
|
|
924
|
+
const inbound = await startWebhookAccount({
|
|
925
|
+
groupPolicy: 'open',
|
|
926
|
+
requireMention: false,
|
|
927
|
+
});
|
|
928
|
+
|
|
929
|
+
mockState.api.getMessage.mockResolvedValueOnce({
|
|
930
|
+
message: {
|
|
931
|
+
id: 999,
|
|
932
|
+
chatId: 520,
|
|
933
|
+
authorId: 55,
|
|
934
|
+
date: '2026-03-19T15:00:00+02:00',
|
|
935
|
+
text: 'сообщение из API',
|
|
936
|
+
isSystem: false,
|
|
937
|
+
uuid: '',
|
|
938
|
+
forward: null,
|
|
939
|
+
params: {},
|
|
940
|
+
viewedByOthers: true,
|
|
941
|
+
},
|
|
942
|
+
user: {
|
|
943
|
+
id: 55,
|
|
944
|
+
active: true,
|
|
945
|
+
name: 'Carol',
|
|
946
|
+
firstName: 'Carol',
|
|
947
|
+
lastName: '',
|
|
948
|
+
workPosition: '',
|
|
949
|
+
color: '#ffffff',
|
|
950
|
+
avatar: '',
|
|
951
|
+
gender: 'F',
|
|
952
|
+
birthday: '',
|
|
953
|
+
extranet: false,
|
|
954
|
+
bot: false,
|
|
955
|
+
connector: false,
|
|
956
|
+
externalAuthId: '',
|
|
957
|
+
status: 'online',
|
|
958
|
+
idle: false,
|
|
959
|
+
lastActivityDate: false,
|
|
960
|
+
absent: false,
|
|
961
|
+
departments: [],
|
|
962
|
+
phones: false,
|
|
963
|
+
type: 'employee',
|
|
964
|
+
},
|
|
965
|
+
});
|
|
966
|
+
|
|
967
|
+
const onMessage = inbound.onMessage as (ctx: B24MsgContext) => Promise<void>;
|
|
968
|
+
|
|
969
|
+
await onMessage(createGroupMessage({
|
|
970
|
+
messageId: '203',
|
|
971
|
+
text: 'ответ на сообщение из API',
|
|
972
|
+
replyToMessageId: '999',
|
|
973
|
+
senderName: 'Bob',
|
|
974
|
+
senderId: '77',
|
|
975
|
+
wasMentioned: false,
|
|
976
|
+
}));
|
|
977
|
+
|
|
978
|
+
const inboundCtx = mockState.runtime.channel.reply.finalizeInboundContext.mock.calls[0][0] as Record<string, unknown>;
|
|
979
|
+
expect(inboundCtx.ReplyToId).toBe('999');
|
|
980
|
+
expect(inboundCtx.ReplyToBody).toBe('сообщение из API');
|
|
981
|
+
expect(inboundCtx.ReplyToSender).toBe('Carol');
|
|
982
|
+
expect(String(inboundCtx.Body)).toContain('[Replying to Carol id:999]');
|
|
983
|
+
expect(mockState.api.getMessage).toHaveBeenCalledWith(
|
|
984
|
+
'https://test.bitrix24.com/rest/1/token/',
|
|
985
|
+
expect.objectContaining({ botId: 6809 }),
|
|
986
|
+
999,
|
|
987
|
+
);
|
|
988
|
+
});
|
|
989
|
+
|
|
990
|
+
it('loads referenced group history in DM by Bitrix chat mention', async () => {
|
|
991
|
+
const inbound = await startWebhookAccount({
|
|
992
|
+
groupPolicy: 'open',
|
|
993
|
+
requireMention: false,
|
|
994
|
+
});
|
|
995
|
+
|
|
996
|
+
const onMessage = inbound.onMessage as (ctx: B24MsgContext) => Promise<void>;
|
|
997
|
+
|
|
998
|
+
await onMessage(createGroupMessage({
|
|
999
|
+
messageId: '401',
|
|
1000
|
+
text: 'первое сообщение в группе',
|
|
1001
|
+
senderName: 'Алиса',
|
|
1002
|
+
senderId: '42',
|
|
1003
|
+
wasMentioned: false,
|
|
1004
|
+
}));
|
|
1005
|
+
await onMessage(createGroupMessage({
|
|
1006
|
+
messageId: '402',
|
|
1007
|
+
text: 'второе сообщение в группе',
|
|
1008
|
+
senderName: 'Боб',
|
|
1009
|
+
senderId: '77',
|
|
1010
|
+
wasMentioned: false,
|
|
1011
|
+
timestamp: Date.parse('2026-03-19T15:10:10+02:00'),
|
|
1012
|
+
}));
|
|
1013
|
+
|
|
1014
|
+
mockState.runtime.channel.reply.finalizeInboundContext.mockClear();
|
|
1015
|
+
mockState.runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher.mockClear();
|
|
1016
|
+
|
|
1017
|
+
await onMessage(createDirectMessage({
|
|
1018
|
+
messageId: '403',
|
|
1019
|
+
text: 'что было в [CHAT=520]Чат зеленый 17[/CHAT]?',
|
|
1020
|
+
replyToMessageId: 'dm-chat-mention',
|
|
1021
|
+
timestamp: Date.parse('2026-03-19T15:10:20+02:00'),
|
|
1022
|
+
}));
|
|
1023
|
+
|
|
1024
|
+
const inboundCtx = mockState.runtime.channel.reply.finalizeInboundContext.mock.calls[0][0] as Record<string, unknown>;
|
|
1025
|
+
expect(String(inboundCtx.Body)).toContain('[Visible group chat history: [CHAT=520]Чат зеленый 17[/CHAT]]');
|
|
1026
|
+
expect(String(inboundCtx.Body)).toContain('[Алиса] первое сообщение в группе');
|
|
1027
|
+
expect(String(inboundCtx.Body)).toContain('[Боб] второе сообщение в группе');
|
|
1028
|
+
expect(String(inboundCtx.BodyForAgent)).toContain('[CHAT=520]Чат зеленый 17[/CHAT]');
|
|
1029
|
+
});
|
|
1030
|
+
|
|
1031
|
+
it('hydrates standalone forwarded messages from Bitrix24 context instead of sending a stub notice', async () => {
|
|
1032
|
+
const inbound = await startWebhookAccount({
|
|
1033
|
+
groupPolicy: 'open',
|
|
1034
|
+
requireMention: false,
|
|
1035
|
+
});
|
|
1036
|
+
|
|
1037
|
+
mockState.api.getMessageContext.mockResolvedValueOnce({
|
|
1038
|
+
messages: [
|
|
1039
|
+
{
|
|
1040
|
+
id: 299,
|
|
1041
|
+
chatId: 520,
|
|
1042
|
+
authorId: 42,
|
|
1043
|
+
date: '2026-03-19T15:01:00+02:00',
|
|
1044
|
+
text: 'что там в пересланном сообщении?',
|
|
1045
|
+
isSystem: false,
|
|
1046
|
+
uuid: '',
|
|
1047
|
+
forward: null,
|
|
1048
|
+
params: {},
|
|
1049
|
+
viewedByOthers: true,
|
|
1050
|
+
},
|
|
1051
|
+
{
|
|
1052
|
+
id: 300,
|
|
1053
|
+
chatId: 520,
|
|
1054
|
+
authorId: 77,
|
|
1055
|
+
date: '2026-03-19T15:01:03+02:00',
|
|
1056
|
+
text: 'пересланный текст',
|
|
1057
|
+
isSystem: false,
|
|
1058
|
+
uuid: '',
|
|
1059
|
+
forward: { id: 1, userId: 77, chatId: 520, date: '2026-03-19T15:01:03+02:00' },
|
|
1060
|
+
params: {},
|
|
1061
|
+
viewedByOthers: true,
|
|
1062
|
+
},
|
|
1063
|
+
],
|
|
1064
|
+
users: [
|
|
1065
|
+
{
|
|
1066
|
+
id: 42,
|
|
1067
|
+
active: true,
|
|
1068
|
+
name: 'Alice',
|
|
1069
|
+
firstName: 'Alice',
|
|
1070
|
+
lastName: '',
|
|
1071
|
+
workPosition: '',
|
|
1072
|
+
color: '#ffffff',
|
|
1073
|
+
avatar: '',
|
|
1074
|
+
gender: 'F',
|
|
1075
|
+
birthday: '',
|
|
1076
|
+
extranet: false,
|
|
1077
|
+
bot: false,
|
|
1078
|
+
connector: false,
|
|
1079
|
+
externalAuthId: '',
|
|
1080
|
+
status: 'online',
|
|
1081
|
+
idle: false,
|
|
1082
|
+
lastActivityDate: false,
|
|
1083
|
+
absent: false,
|
|
1084
|
+
departments: [],
|
|
1085
|
+
phones: false,
|
|
1086
|
+
type: 'employee',
|
|
1087
|
+
},
|
|
1088
|
+
{
|
|
1089
|
+
id: 77,
|
|
1090
|
+
active: true,
|
|
1091
|
+
name: 'Bob',
|
|
1092
|
+
firstName: 'Bob',
|
|
1093
|
+
lastName: '',
|
|
1094
|
+
workPosition: '',
|
|
1095
|
+
color: '#ffffff',
|
|
1096
|
+
avatar: '',
|
|
1097
|
+
gender: 'M',
|
|
1098
|
+
birthday: '',
|
|
1099
|
+
extranet: false,
|
|
1100
|
+
bot: false,
|
|
1101
|
+
connector: false,
|
|
1102
|
+
externalAuthId: '',
|
|
1103
|
+
status: 'online',
|
|
1104
|
+
idle: false,
|
|
1105
|
+
lastActivityDate: false,
|
|
1106
|
+
absent: false,
|
|
1107
|
+
departments: [],
|
|
1108
|
+
phones: false,
|
|
1109
|
+
type: 'employee',
|
|
1110
|
+
},
|
|
1111
|
+
],
|
|
1112
|
+
hasPrevPage: false,
|
|
1113
|
+
hasNextPage: false,
|
|
1114
|
+
});
|
|
1115
|
+
|
|
1116
|
+
const onMessage = inbound.onMessage as (ctx: B24MsgContext) => Promise<void>;
|
|
1117
|
+
|
|
1118
|
+
await onMessage(createGroupMessage({
|
|
1119
|
+
messageId: '300',
|
|
1120
|
+
text: 'пересланный текст',
|
|
1121
|
+
senderName: 'Bob',
|
|
1122
|
+
senderId: '77',
|
|
1123
|
+
isForwarded: true,
|
|
1124
|
+
wasMentioned: false,
|
|
1125
|
+
}));
|
|
1126
|
+
|
|
1127
|
+
const inboundCtx = mockState.runtime.channel.reply.finalizeInboundContext.mock.calls[0][0] as Record<string, unknown>;
|
|
1128
|
+
expect(String(inboundCtx.Body)).toContain('пересланный текст');
|
|
1129
|
+
expect(String(inboundCtx.Body)).toContain('[Forwarded message body]');
|
|
1130
|
+
expect(String(inboundCtx.Body)).toContain('[/Forwarded message body]');
|
|
1131
|
+
expect(String(inboundCtx.Body)).toContain('[Bitrix24 surrounding context around this forwarded message - not the forwarded message body]');
|
|
1132
|
+
expect(String(inboundCtx.Body)).toContain('[Alice id:299] что там в пересланном сообщении?');
|
|
1133
|
+
expect(mockState.api.getMessageContext).toHaveBeenCalledWith(
|
|
1134
|
+
'https://test.bitrix24.com/rest/1/token/',
|
|
1135
|
+
expect.objectContaining({ botId: 6809 }),
|
|
1136
|
+
300,
|
|
1137
|
+
5,
|
|
1138
|
+
);
|
|
1139
|
+
expect(mockState.sendService.sendText).not.toHaveBeenCalled();
|
|
1140
|
+
expect(mockState.runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1);
|
|
1141
|
+
});
|
|
1142
|
+
|
|
1143
|
+
it('hydrates and prioritizes the forwarded message when it follows a buffered direct question', async () => {
|
|
1144
|
+
const inbound = await startWebhookAccount({
|
|
1145
|
+
groupPolicy: 'open',
|
|
1146
|
+
requireMention: false,
|
|
1147
|
+
});
|
|
1148
|
+
|
|
1149
|
+
mockState.api.getMessageContext.mockResolvedValueOnce({
|
|
1150
|
+
messages: [
|
|
1151
|
+
{
|
|
1152
|
+
id: 500,
|
|
1153
|
+
chatId: 1,
|
|
1154
|
+
authorId: 1,
|
|
1155
|
+
date: '2026-03-19T23:46:00+02:00',
|
|
1156
|
+
text: 'Message for all',
|
|
1157
|
+
isSystem: false,
|
|
1158
|
+
uuid: '',
|
|
1159
|
+
forward: { id: 1, userId: 1, chatId: 520, date: '2026-03-19T23:46:00+02:00' },
|
|
1160
|
+
params: {},
|
|
1161
|
+
viewedByOthers: true,
|
|
1162
|
+
},
|
|
1163
|
+
{
|
|
1164
|
+
id: 499,
|
|
1165
|
+
chatId: 1,
|
|
1166
|
+
authorId: 77,
|
|
1167
|
+
date: '2026-03-19T23:45:58+02:00',
|
|
1168
|
+
text: 'context before forwarded message',
|
|
1169
|
+
isSystem: false,
|
|
1170
|
+
uuid: '',
|
|
1171
|
+
forward: null,
|
|
1172
|
+
params: {},
|
|
1173
|
+
viewedByOthers: true,
|
|
1174
|
+
},
|
|
1175
|
+
],
|
|
1176
|
+
users: [
|
|
1177
|
+
{
|
|
1178
|
+
id: 1,
|
|
1179
|
+
active: true,
|
|
1180
|
+
name: 'Евгений Шеленков',
|
|
1181
|
+
firstName: 'Евгений',
|
|
1182
|
+
lastName: 'Шеленков',
|
|
1183
|
+
workPosition: '',
|
|
1184
|
+
color: '#ffffff',
|
|
1185
|
+
avatar: '',
|
|
1186
|
+
gender: 'M',
|
|
1187
|
+
birthday: '',
|
|
1188
|
+
extranet: false,
|
|
1189
|
+
bot: false,
|
|
1190
|
+
connector: false,
|
|
1191
|
+
externalAuthId: '',
|
|
1192
|
+
status: 'online',
|
|
1193
|
+
idle: false,
|
|
1194
|
+
lastActivityDate: false,
|
|
1195
|
+
absent: false,
|
|
1196
|
+
departments: [],
|
|
1197
|
+
phones: false,
|
|
1198
|
+
type: 'employee',
|
|
1199
|
+
},
|
|
1200
|
+
{
|
|
1201
|
+
id: 77,
|
|
1202
|
+
active: true,
|
|
1203
|
+
name: 'Bob',
|
|
1204
|
+
firstName: 'Bob',
|
|
1205
|
+
lastName: '',
|
|
1206
|
+
workPosition: '',
|
|
1207
|
+
color: '#ffffff',
|
|
1208
|
+
avatar: '',
|
|
1209
|
+
gender: 'M',
|
|
1210
|
+
birthday: '',
|
|
1211
|
+
extranet: false,
|
|
1212
|
+
bot: false,
|
|
1213
|
+
connector: false,
|
|
1214
|
+
externalAuthId: '',
|
|
1215
|
+
status: 'online',
|
|
1216
|
+
idle: false,
|
|
1217
|
+
lastActivityDate: false,
|
|
1218
|
+
absent: false,
|
|
1219
|
+
departments: [],
|
|
1220
|
+
phones: false,
|
|
1221
|
+
type: 'employee',
|
|
1222
|
+
},
|
|
1223
|
+
],
|
|
1224
|
+
hasPrevPage: false,
|
|
1225
|
+
hasNextPage: false,
|
|
1226
|
+
});
|
|
1227
|
+
|
|
1228
|
+
const onMessage = inbound.onMessage as (ctx: B24MsgContext) => Promise<void>;
|
|
1229
|
+
|
|
1230
|
+
await onMessage(createDirectMessage({
|
|
1231
|
+
messageId: '499',
|
|
1232
|
+
text: 'что в пересланном сообщении - можешь сказать слово в слово',
|
|
1233
|
+
replyToMessageId: undefined,
|
|
1234
|
+
timestamp: Date.parse('2026-03-19T23:45:59+02:00'),
|
|
1235
|
+
}));
|
|
1236
|
+
|
|
1237
|
+
await onMessage(createDirectMessage({
|
|
1238
|
+
messageId: '500',
|
|
1239
|
+
text: '',
|
|
1240
|
+
isForwarded: true,
|
|
1241
|
+
replyToMessageId: '201',
|
|
1242
|
+
timestamp: Date.parse('2026-03-19T23:46:00+02:00'),
|
|
1243
|
+
}));
|
|
1244
|
+
|
|
1245
|
+
const inboundCtx = mockState.runtime.channel.reply.finalizeInboundContext.mock.calls[0][0] as Record<string, unknown>;
|
|
1246
|
+
expect(String(inboundCtx.Body)).toContain('[User question about the forwarded message]');
|
|
1247
|
+
expect(String(inboundCtx.Body)).toContain('[/User question]');
|
|
1248
|
+
expect(String(inboundCtx.Body)).toContain('[Forwarded message body]');
|
|
1249
|
+
expect(String(inboundCtx.Body)).toContain('[/Forwarded message body]');
|
|
1250
|
+
expect(String(inboundCtx.Body)).toContain('Message for all');
|
|
1251
|
+
expect(String(inboundCtx.Body)).toContain('[Bitrix24 surrounding context around this forwarded message - not the forwarded message body]');
|
|
1252
|
+
expect(inboundCtx.ReplyToBody).toBeUndefined();
|
|
1253
|
+
expect(inboundCtx.ReplyToId).toBeUndefined();
|
|
1254
|
+
expect(mockState.api.getMessageContext).toHaveBeenCalledWith(
|
|
1255
|
+
'https://test.bitrix24.com/rest/1/token/',
|
|
1256
|
+
expect.objectContaining({ botId: 6809 }),
|
|
1257
|
+
500,
|
|
1258
|
+
5,
|
|
1259
|
+
);
|
|
1260
|
+
});
|
|
1261
|
+
|
|
1262
|
+
it('loads referenced group history in group messages by explicit Bitrix chat mention', async () => {
|
|
1263
|
+
const inbound = await startWebhookAccount({
|
|
1264
|
+
groupPolicy: 'open',
|
|
1265
|
+
requireMention: false,
|
|
1266
|
+
});
|
|
1267
|
+
|
|
1268
|
+
const onMessage = inbound.onMessage as (ctx: B24MsgContext) => Promise<void>;
|
|
1269
|
+
|
|
1270
|
+
await onMessage(createGroupMessage({
|
|
1271
|
+
messageId: '501',
|
|
1272
|
+
chatId: 'chat615',
|
|
1273
|
+
chatInternalId: '615',
|
|
1274
|
+
chatName: 'Синий чат 15',
|
|
1275
|
+
text: 'секретная заметка из синего чата',
|
|
1276
|
+
senderName: 'Алиса',
|
|
1277
|
+
senderId: '42',
|
|
1278
|
+
wasMentioned: false,
|
|
1279
|
+
}));
|
|
1280
|
+
|
|
1281
|
+
mockState.runtime.channel.reply.finalizeInboundContext.mockClear();
|
|
1282
|
+
mockState.runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher.mockClear();
|
|
1283
|
+
|
|
1284
|
+
await onMessage(createGroupMessage({
|
|
1285
|
+
messageId: '502',
|
|
1286
|
+
chatId: 'chat520',
|
|
1287
|
+
chatInternalId: '520',
|
|
1288
|
+
chatName: 'Чат зеленый 17',
|
|
1289
|
+
text: 'что было в [CHAT=615]Синий чат 15[/CHAT]?',
|
|
1290
|
+
senderName: 'Евгений Шеленков',
|
|
1291
|
+
senderId: '1',
|
|
1292
|
+
wasMentioned: false,
|
|
1293
|
+
}));
|
|
1294
|
+
|
|
1295
|
+
const inboundCtx = mockState.runtime.channel.reply.finalizeInboundContext.mock.calls[0][0] as Record<string, unknown>;
|
|
1296
|
+
expect(String(inboundCtx.Body)).toContain('[Visible group chat history: [CHAT=615]Синий чат 15[/CHAT]]');
|
|
1297
|
+
expect(String(inboundCtx.Body)).toContain('секретная заметка из синего чата');
|
|
1298
|
+
});
|
|
1299
|
+
|
|
1300
|
+
it('does not load cross-chat history in DM without explicit Bitrix chat mention', async () => {
|
|
1301
|
+
const inbound = await startWebhookAccount({
|
|
1302
|
+
groupPolicy: 'open',
|
|
1303
|
+
requireMention: false,
|
|
1304
|
+
});
|
|
1305
|
+
|
|
1306
|
+
const onMessage = inbound.onMessage as (ctx: B24MsgContext) => Promise<void>;
|
|
1307
|
+
|
|
1308
|
+
await onMessage(createGroupMessage({
|
|
1309
|
+
messageId: '411',
|
|
1310
|
+
text: 'секреты зеленого чата',
|
|
1311
|
+
senderName: 'Алиса',
|
|
1312
|
+
wasMentioned: false,
|
|
1313
|
+
}));
|
|
1314
|
+
|
|
1315
|
+
mockState.runtime.channel.reply.finalizeInboundContext.mockClear();
|
|
1316
|
+
mockState.runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher.mockClear();
|
|
1317
|
+
|
|
1318
|
+
await onMessage(createDirectMessage({
|
|
1319
|
+
messageId: '412',
|
|
1320
|
+
text: 'что было в чате зеленый 17?',
|
|
1321
|
+
replyToMessageId: 'dm-chat-name',
|
|
1322
|
+
timestamp: Date.parse('2026-03-19T15:10:30+02:00'),
|
|
1323
|
+
}));
|
|
1324
|
+
|
|
1325
|
+
const inboundCtx = mockState.runtime.channel.reply.finalizeInboundContext.mock.calls[0][0] as Record<string, unknown>;
|
|
1326
|
+
expect(String(inboundCtx.Body)).not.toContain('[Visible group chat history:');
|
|
1327
|
+
expect(String(inboundCtx.Body)).not.toContain('секреты зеленого чата');
|
|
1328
|
+
expect(String(inboundCtx.BodyForAgent)).toBe('что было в чате зеленый 17?');
|
|
1329
|
+
});
|
|
1330
|
+
|
|
1331
|
+
it('answers watched user messages on matching topics in group chat', async () => {
|
|
1332
|
+
const inbound = await startWebhookAccount({
|
|
1333
|
+
groupPolicy: 'webhookUser',
|
|
1334
|
+
requireMention: true,
|
|
1335
|
+
groups: {
|
|
1336
|
+
chat520: {
|
|
1337
|
+
watch: [
|
|
1338
|
+
{ userId: '77', topics: ['секрет'] },
|
|
1339
|
+
],
|
|
1340
|
+
},
|
|
1341
|
+
},
|
|
1342
|
+
});
|
|
1343
|
+
|
|
1344
|
+
const onMessage = inbound.onMessage as (ctx: B24MsgContext) => Promise<void>;
|
|
1345
|
+
|
|
1346
|
+
await onMessage(createGroupMessage({
|
|
1347
|
+
messageId: '701',
|
|
1348
|
+
text: 'другая тема',
|
|
1349
|
+
senderId: '77',
|
|
1350
|
+
senderName: 'Сергей Рыжиков',
|
|
1351
|
+
wasMentioned: false,
|
|
1352
|
+
}));
|
|
1353
|
+
|
|
1354
|
+
expect(mockState.runtime.channel.reply.finalizeInboundContext).not.toHaveBeenCalled();
|
|
1355
|
+
|
|
1356
|
+
await onMessage(createGroupMessage({
|
|
1357
|
+
messageId: '702',
|
|
1358
|
+
text: 'секретный секрет',
|
|
1359
|
+
senderId: '77',
|
|
1360
|
+
senderName: 'Сергей Рыжиков',
|
|
1361
|
+
wasMentioned: false,
|
|
1362
|
+
timestamp: Date.parse('2026-03-19T15:12:00+02:00'),
|
|
1363
|
+
}));
|
|
1364
|
+
|
|
1365
|
+
expect(mockState.sendService.markRead).toHaveBeenCalledTimes(2);
|
|
1366
|
+
expect(mockState.sendService.sendText).not.toHaveBeenCalled();
|
|
1367
|
+
expect(mockState.runtime.channel.reply.finalizeInboundContext).toHaveBeenCalledTimes(1);
|
|
1368
|
+
const inboundCtx = mockState.runtime.channel.reply.finalizeInboundContext.mock.calls[0][0] as Record<string, unknown>;
|
|
1369
|
+
expect(inboundCtx.InboundHistory).toEqual([
|
|
1370
|
+
{
|
|
1371
|
+
sender: 'Сергей Рыжиков',
|
|
1372
|
+
body: 'другая тема',
|
|
1373
|
+
timestamp: Date.parse('2026-03-19T14:36:03+02:00'),
|
|
1374
|
+
},
|
|
1375
|
+
]);
|
|
1376
|
+
expect(inboundCtx.WasMentioned).toBe(false);
|
|
1377
|
+
expect(String(inboundCtx.BodyForAgent)).toContain('секретный секрет');
|
|
1378
|
+
});
|
|
1379
|
+
|
|
1380
|
+
it('notifies the webhook owner in DM with a native forward when watch mode is notifyOwnerDm', async () => {
|
|
1381
|
+
const inbound = await startWebhookAccount({
|
|
1382
|
+
groupPolicy: 'webhookUser',
|
|
1383
|
+
requireMention: true,
|
|
1384
|
+
groups: {
|
|
1385
|
+
chat520: {
|
|
1386
|
+
watch: [
|
|
1387
|
+
{ userId: '10', topics: ['секрет'], mode: 'notifyOwnerDm' },
|
|
1388
|
+
],
|
|
1389
|
+
},
|
|
1390
|
+
},
|
|
1391
|
+
});
|
|
1392
|
+
|
|
1393
|
+
const onMessage = inbound.onMessage as (ctx: B24MsgContext) => Promise<void>;
|
|
1394
|
+
|
|
1395
|
+
await onMessage(createGroupMessage({
|
|
1396
|
+
messageId: '710',
|
|
1397
|
+
text: 'секретный комментарий',
|
|
1398
|
+
senderId: '10',
|
|
1399
|
+
senderName: 'Watched User',
|
|
1400
|
+
wasMentioned: false,
|
|
1401
|
+
}));
|
|
1402
|
+
|
|
1403
|
+
expect(mockState.sendService.markRead).toHaveBeenCalledTimes(1);
|
|
1404
|
+
expect(mockState.runtime.channel.reply.finalizeInboundContext).not.toHaveBeenCalled();
|
|
1405
|
+
expect(mockState.runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
|
|
1406
|
+
expect(mockState.api.addReaction).not.toHaveBeenCalled();
|
|
1407
|
+
expect(mockState.sendService.sendText).toHaveBeenCalledTimes(1);
|
|
1408
|
+
expect(mockState.sendService.sendText).toHaveBeenCalledWith(
|
|
1409
|
+
expect.objectContaining({
|
|
1410
|
+
dialogId: '1',
|
|
1411
|
+
}),
|
|
1412
|
+
expect.stringContaining('Сработало правило отслеживания в чате'),
|
|
1413
|
+
{
|
|
1414
|
+
convertMarkdown: false,
|
|
1415
|
+
},
|
|
1416
|
+
);
|
|
1417
|
+
expect(mockState.sendService.sendText.mock.calls[0][1]).toContain('[URL=/online/?IM_DIALOG=chat520&IM_MESSAGE=710]');
|
|
1418
|
+
expect(mockState.sendService.sendText.mock.calls[0][1]).toContain('Совпавшие темы: [b]секрет[/b]');
|
|
1419
|
+
expect(mockState.api.sendMessage).toHaveBeenCalledWith(
|
|
1420
|
+
'https://test.bitrix24.com/rest/1/token/',
|
|
1421
|
+
expect.objectContaining({
|
|
1422
|
+
botId: 6809,
|
|
1423
|
+
}),
|
|
1424
|
+
'1',
|
|
1425
|
+
null,
|
|
1426
|
+
{ forwardMessages: [710] },
|
|
1427
|
+
);
|
|
1428
|
+
});
|
|
1429
|
+
|
|
1430
|
+
it('matches any sender when watch userId is wildcard', async () => {
|
|
1431
|
+
const inbound = await startWebhookAccount({
|
|
1432
|
+
groupPolicy: 'webhookUser',
|
|
1433
|
+
requireMention: true,
|
|
1434
|
+
groups: {
|
|
1435
|
+
chat520: {
|
|
1436
|
+
watch: [
|
|
1437
|
+
{ userId: '*', topics: ['авария'], mode: 'notifyOwnerDm' },
|
|
1438
|
+
],
|
|
1439
|
+
},
|
|
1440
|
+
},
|
|
1441
|
+
});
|
|
1442
|
+
|
|
1443
|
+
const onMessage = inbound.onMessage as (ctx: B24MsgContext) => Promise<void>;
|
|
1444
|
+
|
|
1445
|
+
await onMessage(createGroupMessage({
|
|
1446
|
+
messageId: '711',
|
|
1447
|
+
text: 'авария на линии',
|
|
1448
|
+
senderId: '99',
|
|
1449
|
+
senderName: 'Any User',
|
|
1450
|
+
wasMentioned: false,
|
|
1451
|
+
}));
|
|
1452
|
+
|
|
1453
|
+
expect(mockState.sendService.sendText).toHaveBeenCalledTimes(1);
|
|
1454
|
+
expect(mockState.sendService.sendText).toHaveBeenCalledWith(
|
|
1455
|
+
expect.objectContaining({
|
|
1456
|
+
dialogId: '1',
|
|
1457
|
+
}),
|
|
1458
|
+
expect.stringContaining('Сработало правило отслеживания в чате'),
|
|
1459
|
+
{
|
|
1460
|
+
convertMarkdown: false,
|
|
1461
|
+
},
|
|
1462
|
+
);
|
|
1463
|
+
expect(mockState.sendService.sendText.mock.calls[0][1]).toContain('Совпавшие темы: [b]авария[/b]');
|
|
1464
|
+
expect(mockState.api.sendMessage).toHaveBeenCalledWith(
|
|
1465
|
+
'https://test.bitrix24.com/rest/1/token/',
|
|
1466
|
+
expect.objectContaining({
|
|
1467
|
+
botId: 6809,
|
|
1468
|
+
}),
|
|
1469
|
+
'1',
|
|
1470
|
+
null,
|
|
1471
|
+
{ forwardMessages: [711] },
|
|
1472
|
+
);
|
|
1473
|
+
expect(mockState.runtime.channel.reply.finalizeInboundContext).not.toHaveBeenCalled();
|
|
1474
|
+
});
|
|
1475
|
+
|
|
1476
|
+
it('does not notify the webhook owner about their own notifyOwnerDm watch match', async () => {
|
|
1477
|
+
const inbound = await startWebhookAccount({
|
|
1478
|
+
groupPolicy: 'webhookUser',
|
|
1479
|
+
requireMention: true,
|
|
1480
|
+
groups: {
|
|
1481
|
+
chat520: {
|
|
1482
|
+
watch: [
|
|
1483
|
+
{ userId: '*', topics: ['авария'], mode: 'notifyOwnerDm' },
|
|
1484
|
+
],
|
|
1485
|
+
},
|
|
1486
|
+
},
|
|
1487
|
+
});
|
|
1488
|
+
|
|
1489
|
+
const onMessage = inbound.onMessage as (ctx: B24MsgContext) => Promise<void>;
|
|
1490
|
+
|
|
1491
|
+
await onMessage(createGroupMessage({
|
|
1492
|
+
messageId: '711-own',
|
|
1493
|
+
text: 'авария у нас снова',
|
|
1494
|
+
senderId: '1',
|
|
1495
|
+
senderName: 'Евгений Шеленков',
|
|
1496
|
+
wasMentioned: false,
|
|
1497
|
+
}));
|
|
1498
|
+
|
|
1499
|
+
expect(mockState.sendService.markRead).toHaveBeenCalledTimes(1);
|
|
1500
|
+
expect(mockState.sendService.sendText).not.toHaveBeenCalled();
|
|
1501
|
+
expect(mockState.api.sendMessage).not.toHaveBeenCalled();
|
|
1502
|
+
expect(mockState.runtime.channel.reply.finalizeInboundContext).not.toHaveBeenCalled();
|
|
1503
|
+
expect(mockState.runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
|
|
1504
|
+
});
|
|
1505
|
+
|
|
1506
|
+
it('applies wildcard group watch rules even when the group has its own override config', async () => {
|
|
1507
|
+
const inbound = await startWebhookAccount({
|
|
1508
|
+
groupPolicy: 'webhookUser',
|
|
1509
|
+
requireMention: true,
|
|
1510
|
+
groups: {
|
|
1511
|
+
'*': {
|
|
1512
|
+
watch: [
|
|
1513
|
+
{ userId: '*', topics: ['авария'], mode: 'notifyOwnerDm' },
|
|
1514
|
+
],
|
|
1515
|
+
},
|
|
1516
|
+
chat520: {
|
|
1517
|
+
requireMention: false,
|
|
1518
|
+
},
|
|
1519
|
+
},
|
|
1520
|
+
});
|
|
1521
|
+
|
|
1522
|
+
const onMessage = inbound.onMessage as (ctx: B24MsgContext) => Promise<void>;
|
|
1523
|
+
|
|
1524
|
+
await onMessage(createGroupMessage({
|
|
1525
|
+
messageId: '712',
|
|
1526
|
+
text: 'авария на линии',
|
|
1527
|
+
senderId: '55',
|
|
1528
|
+
senderName: 'Wildcard User',
|
|
1529
|
+
wasMentioned: false,
|
|
1530
|
+
}));
|
|
1531
|
+
|
|
1532
|
+
expect(mockState.sendService.sendText).toHaveBeenCalledTimes(1);
|
|
1533
|
+
expect(mockState.sendService.sendText).toHaveBeenCalledWith(
|
|
1534
|
+
expect.objectContaining({
|
|
1535
|
+
dialogId: '1',
|
|
1536
|
+
}),
|
|
1537
|
+
expect.stringContaining('Сработало правило отслеживания в чате'),
|
|
1538
|
+
{
|
|
1539
|
+
convertMarkdown: false,
|
|
1540
|
+
},
|
|
1541
|
+
);
|
|
1542
|
+
expect(mockState.api.sendMessage).toHaveBeenCalledWith(
|
|
1543
|
+
'https://test.bitrix24.com/rest/1/token/',
|
|
1544
|
+
expect.objectContaining({
|
|
1545
|
+
botId: 6809,
|
|
1546
|
+
}),
|
|
1547
|
+
'1',
|
|
1548
|
+
null,
|
|
1549
|
+
{ forwardMessages: [712] },
|
|
1550
|
+
);
|
|
1551
|
+
expect(mockState.runtime.channel.reply.finalizeInboundContext).not.toHaveBeenCalled();
|
|
1552
|
+
});
|
|
1553
|
+
|
|
1554
|
+
it('captures user events in agent mode and notifies owner DM when agentWatch matches', async () => {
|
|
1555
|
+
const inbound = await startWebhookAccount({
|
|
1556
|
+
agentMode: true,
|
|
1557
|
+
agentWatch: {
|
|
1558
|
+
'*': [
|
|
1559
|
+
{ userId: '*', topics: ['авария'], mode: 'notifyOwnerDm' },
|
|
1560
|
+
],
|
|
1561
|
+
},
|
|
1562
|
+
});
|
|
1563
|
+
|
|
1564
|
+
const onMessage = inbound.onMessage as (ctx: B24MsgContext) => Promise<void>;
|
|
1565
|
+
|
|
1566
|
+
await onMessage(createDirectMessage({
|
|
1567
|
+
eventScope: 'user',
|
|
1568
|
+
messageId: '9001',
|
|
1569
|
+
chatId: '77',
|
|
1570
|
+
chatInternalId: '99001',
|
|
1571
|
+
chatName: 'Сергей Рыжиков',
|
|
1572
|
+
text: 'авария в личке',
|
|
1573
|
+
senderId: '77',
|
|
1574
|
+
senderName: 'Сергей Рыжиков',
|
|
1575
|
+
isDm: true,
|
|
1576
|
+
isGroup: false,
|
|
1577
|
+
wasMentioned: false,
|
|
1578
|
+
}));
|
|
1579
|
+
|
|
1580
|
+
expect(mockState.sendService.markRead).not.toHaveBeenCalled();
|
|
1581
|
+
expect(mockState.runtime.channel.reply.finalizeInboundContext).not.toHaveBeenCalled();
|
|
1582
|
+
expect(mockState.sendService.sendText).toHaveBeenCalledTimes(2);
|
|
1583
|
+
expect(mockState.sendService.sendText).toHaveBeenCalledWith(
|
|
1584
|
+
expect.objectContaining({ dialogId: '1' }),
|
|
1585
|
+
expect.stringContaining('Сработало правило отслеживания в личном чате с'),
|
|
1586
|
+
{ convertMarkdown: false },
|
|
1587
|
+
);
|
|
1588
|
+
expect(mockState.sendService.sendText.mock.calls[0][1]).toContain('[URL=/online/?IM_DIALOG=77&IM_MESSAGE=9001]Сергей Рыжиков[/URL]');
|
|
1589
|
+
expect(String(mockState.sendService.sendText.mock.calls[1][1])).toContain('Сергей Рыжиков');
|
|
1590
|
+
expect(String(mockState.sendService.sendText.mock.calls[1][1])).toContain('#77:1/9001');
|
|
1591
|
+
expect(String(mockState.sendService.sendText.mock.calls[1][1])).toContain('авария в личке');
|
|
1592
|
+
expect(mockState.api.sendMessage).not.toHaveBeenCalled();
|
|
1593
|
+
});
|
|
1594
|
+
|
|
1595
|
+
it('ignores agent-mode user events from the direct chat with the bot', async () => {
|
|
1596
|
+
const inbound = await startWebhookAccount({
|
|
1597
|
+
agentMode: true,
|
|
1598
|
+
agentWatch: {
|
|
1599
|
+
'*': [
|
|
1600
|
+
{ userId: '*', topics: ['авария'], mode: 'notifyOwnerDm' },
|
|
1601
|
+
],
|
|
1602
|
+
},
|
|
1603
|
+
});
|
|
1604
|
+
|
|
1605
|
+
const onMessage = inbound.onMessage as (ctx: B24MsgContext) => Promise<void>;
|
|
1606
|
+
|
|
1607
|
+
await onMessage(createDirectMessage({
|
|
1608
|
+
eventScope: 'user',
|
|
1609
|
+
messageId: '9002',
|
|
1610
|
+
chatId: '6809',
|
|
1611
|
+
chatInternalId: '6809',
|
|
1612
|
+
chatName: 'OpenClaw',
|
|
1613
|
+
text: 'авария в личке с ботом',
|
|
1614
|
+
senderId: '6809',
|
|
1615
|
+
senderName: 'OpenClaw',
|
|
1616
|
+
isDm: true,
|
|
1617
|
+
isGroup: false,
|
|
1618
|
+
wasMentioned: false,
|
|
1619
|
+
}));
|
|
1620
|
+
|
|
1621
|
+
expect(mockState.sendService.markRead).not.toHaveBeenCalled();
|
|
1622
|
+
expect(mockState.sendService.sendText).not.toHaveBeenCalled();
|
|
1623
|
+
expect(mockState.api.sendMessage).not.toHaveBeenCalled();
|
|
1624
|
+
expect(mockState.runtime.channel.reply.finalizeInboundContext).not.toHaveBeenCalled();
|
|
1625
|
+
});
|
|
1626
|
+
|
|
1627
|
+
it('sends a notice and leaves disabled group chats on join', async () => {
|
|
1628
|
+
const inbound = await startWebhookAccount({
|
|
1629
|
+
groupPolicy: 'disabled',
|
|
1630
|
+
});
|
|
1631
|
+
|
|
1632
|
+
const onJoinChat = inbound.onJoinChat as (ctx: Record<string, unknown>) => Promise<void>;
|
|
1633
|
+
|
|
1634
|
+
await onJoinChat({
|
|
1635
|
+
senderId: '1',
|
|
1636
|
+
dialogId: 'chat520',
|
|
1637
|
+
chatId: '520',
|
|
1638
|
+
chatType: 'chat',
|
|
1639
|
+
language: 'ru',
|
|
1640
|
+
fetchCtx: {
|
|
1641
|
+
webhookUrl: 'https://test.bitrix24.com/rest/1/token/',
|
|
1642
|
+
botId: 6809,
|
|
1643
|
+
botToken: 'token',
|
|
1644
|
+
},
|
|
1645
|
+
});
|
|
1646
|
+
|
|
1647
|
+
expect(mockState.sendService.sendText).toHaveBeenCalledTimes(1);
|
|
1648
|
+
expect(mockState.api.leaveChat).toHaveBeenCalledTimes(1);
|
|
1649
|
+
expect(mockState.api.leaveChat).toHaveBeenCalledWith(
|
|
1650
|
+
'https://test.bitrix24.com/rest/1/token/',
|
|
1651
|
+
{ botId: 6809, botToken: expect.any(String) },
|
|
1652
|
+
'chat520',
|
|
1653
|
+
);
|
|
1654
|
+
});
|
|
1655
|
+
|
|
1656
|
+
it('sends an access notice and leaves when invited by a user without group access', async () => {
|
|
1657
|
+
const inbound = await startWebhookAccount({
|
|
1658
|
+
groupPolicy: 'webhookUser',
|
|
1659
|
+
requireMention: false,
|
|
1660
|
+
});
|
|
1661
|
+
|
|
1662
|
+
const onJoinChat = inbound.onJoinChat as (ctx: Record<string, unknown>) => Promise<void>;
|
|
1663
|
+
|
|
1664
|
+
await onJoinChat({
|
|
1665
|
+
senderId: '77',
|
|
1666
|
+
dialogId: 'chat520',
|
|
1667
|
+
chatId: '520',
|
|
1668
|
+
chatType: 'chat',
|
|
1669
|
+
language: 'ru',
|
|
1670
|
+
fetchCtx: {
|
|
1671
|
+
webhookUrl: 'https://test.bitrix24.com/rest/1/token/',
|
|
1672
|
+
botId: 6809,
|
|
1673
|
+
botToken: 'token',
|
|
1674
|
+
},
|
|
1675
|
+
});
|
|
1676
|
+
|
|
1677
|
+
expect(mockState.sendService.sendText).toHaveBeenCalledTimes(1);
|
|
1678
|
+
expect(mockState.sendService.sendText).toHaveBeenCalledWith(
|
|
1679
|
+
expect.objectContaining({
|
|
1680
|
+
dialogId: 'chat520',
|
|
1681
|
+
}),
|
|
1682
|
+
'Это персональный бот. Писать ему может только владелец.',
|
|
1683
|
+
{ convertMarkdown: false },
|
|
1684
|
+
);
|
|
1685
|
+
expect(mockState.api.leaveChat).toHaveBeenCalledTimes(1);
|
|
1686
|
+
expect(mockState.api.leaveChat).toHaveBeenCalledWith(
|
|
1687
|
+
'https://test.bitrix24.com/rest/1/token/',
|
|
1688
|
+
{ botId: 6809, botToken: expect.any(String) },
|
|
1689
|
+
'chat520',
|
|
1690
|
+
);
|
|
1691
|
+
});
|
|
1692
|
+
});
|