@ihazz/bitrix24 1.1.1 → 1.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/README.md +5 -0
- package/dist/index.d.ts +30 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +55 -0
- package/dist/index.js.map +1 -0
- package/dist/src/access-control.d.ts +43 -0
- package/dist/src/access-control.d.ts.map +1 -0
- package/dist/src/access-control.js +128 -0
- package/dist/src/access-control.js.map +1 -0
- package/dist/src/api.d.ts +161 -0
- package/dist/src/api.d.ts.map +1 -0
- package/dist/src/api.js +357 -0
- package/dist/src/api.js.map +1 -0
- package/dist/src/bot-avatar.d.ts +7 -0
- package/dist/src/bot-avatar.d.ts.map +1 -0
- package/dist/src/bot-avatar.js +7 -0
- package/dist/src/bot-avatar.js.map +1 -0
- package/dist/src/channel.d.ts +216 -0
- package/dist/src/channel.d.ts.map +1 -0
- package/dist/src/channel.js +2324 -0
- package/dist/src/channel.js.map +1 -0
- package/dist/src/commands.d.ts +22 -0
- package/dist/src/commands.d.ts.map +1 -0
- package/dist/src/commands.js +160 -0
- package/dist/src/commands.js.map +1 -0
- package/dist/src/config-schema.d.ts +356 -0
- package/dist/src/config-schema.d.ts.map +1 -0
- package/dist/src/config-schema.js +43 -0
- package/dist/src/config-schema.js.map +1 -0
- package/dist/src/config.d.ts +11 -0
- package/dist/src/config.d.ts.map +1 -0
- package/dist/src/config.js +50 -0
- package/dist/src/config.js.map +1 -0
- package/dist/src/dedup.d.ts +22 -0
- package/dist/src/dedup.d.ts.map +1 -0
- package/dist/src/dedup.js +49 -0
- package/dist/src/dedup.js.map +1 -0
- package/dist/src/group-access.d.ts +52 -0
- package/dist/src/group-access.d.ts.map +1 -0
- package/dist/src/group-access.js +180 -0
- package/dist/src/group-access.js.map +1 -0
- package/dist/src/history-cache.d.ts +41 -0
- package/dist/src/history-cache.d.ts.map +1 -0
- package/dist/src/history-cache.js +82 -0
- package/dist/src/history-cache.js.map +1 -0
- package/dist/src/i18n.d.ts +22 -0
- package/dist/src/i18n.d.ts.map +1 -0
- package/dist/src/i18n.js +175 -0
- package/dist/src/i18n.js.map +1 -0
- package/dist/src/inbound-handler.d.ts +92 -0
- package/dist/src/inbound-handler.d.ts.map +1 -0
- package/dist/src/inbound-handler.js +417 -0
- package/dist/src/inbound-handler.js.map +1 -0
- package/dist/src/media-service.d.ts +52 -0
- package/dist/src/media-service.d.ts.map +1 -0
- package/dist/src/media-service.js +423 -0
- package/dist/src/media-service.js.map +1 -0
- package/dist/src/message-utils.d.ts +34 -0
- package/dist/src/message-utils.d.ts.map +1 -0
- package/dist/src/message-utils.js +392 -0
- package/dist/src/message-utils.js.map +1 -0
- package/dist/src/polling-service.d.ts +39 -0
- package/dist/src/polling-service.d.ts.map +1 -0
- package/dist/src/polling-service.js +204 -0
- package/dist/src/polling-service.js.map +1 -0
- package/dist/src/rate-limiter.d.ts +22 -0
- package/dist/src/rate-limiter.d.ts.map +1 -0
- package/dist/src/rate-limiter.js +72 -0
- package/dist/src/rate-limiter.js.map +1 -0
- package/dist/src/runtime.d.ts +106 -0
- package/dist/src/runtime.d.ts.map +1 -0
- package/dist/src/runtime.js +11 -0
- package/dist/src/runtime.js.map +1 -0
- package/dist/src/send-service.d.ts +66 -0
- package/dist/src/send-service.d.ts.map +1 -0
- package/dist/src/send-service.js +177 -0
- package/dist/src/send-service.js.map +1 -0
- package/dist/src/state-paths.d.ts +3 -0
- package/dist/src/state-paths.d.ts.map +1 -0
- package/dist/src/state-paths.js +23 -0
- package/dist/src/state-paths.js.map +1 -0
- package/dist/src/types.d.ts +381 -0
- package/dist/src/types.d.ts.map +1 -0
- package/dist/src/types.js +3 -0
- package/dist/src/types.js.map +1 -0
- package/dist/src/utils.d.ts +60 -0
- package/dist/src/utils.d.ts.map +1 -0
- package/dist/src/utils.js +131 -0
- package/dist/src/utils.js.map +1 -0
- package/index.ts +1 -1
- package/openclaw.plugin.json +278 -1
- package/package.json +19 -2
- package/src/api.ts +0 -3
- package/src/channel.ts +76 -73
- package/src/config-schema.ts +1 -2
- package/src/config.ts +6 -8
- package/src/group-access.ts +1 -8
- package/src/inbound-handler.ts +128 -15
- package/src/media-service.ts +229 -61
- package/src/polling-service.ts +2 -3
- package/src/send-service.ts +4 -3
- package/src/state-paths.ts +28 -0
- package/src/types.ts +1 -2
- package/src/utils.ts +31 -4
- package/tests/access-control.test.ts +0 -398
- package/tests/api.test.ts +0 -226
- package/tests/channel-flow.test.ts +0 -1692
- package/tests/channel.test.ts +0 -842
- package/tests/commands.test.ts +0 -57
- package/tests/config.test.ts +0 -210
- package/tests/dedup.test.ts +0 -50
- package/tests/fixtures/onimbotjoinchat.json +0 -48
- package/tests/fixtures/onimbotmessageadd-file.json +0 -86
- package/tests/fixtures/onimbotmessageadd-text.json +0 -59
- package/tests/fixtures/onimcommandadd.json +0 -45
- package/tests/group-access.test.ts +0 -340
- package/tests/history-cache.test.ts +0 -117
- package/tests/i18n.test.ts +0 -90
- package/tests/inbound-handler.test.ts +0 -1033
- package/tests/index.test.ts +0 -94
- package/tests/media-service.test.ts +0 -319
- package/tests/message-utils.test.ts +0 -184
- package/tests/polling-service.test.ts +0 -115
- package/tests/rate-limiter.test.ts +0 -52
- package/tests/send-service.test.ts +0 -162
- package/tsconfig.json +0 -22
- package/vitest.config.ts +0 -9
|
@@ -0,0 +1,2324 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { basename } from 'node:path';
|
|
3
|
+
import { listAccountIds, resolveAccount, getConfig } from './config.js';
|
|
4
|
+
import { Bitrix24Api } from './api.js';
|
|
5
|
+
import { SendService } from './send-service.js';
|
|
6
|
+
import { MediaService } from './media-service.js';
|
|
7
|
+
import { InboundHandler } from './inbound-handler.js';
|
|
8
|
+
import { PollingService } from './polling-service.js';
|
|
9
|
+
import { normalizeAllowEntry, normalizeAllowList, checkAccessWithPairing, getWebhookUserId, } from './access-control.js';
|
|
10
|
+
import { checkGroupAccessPassive, checkGroupAccessWithPairing, resolveAgentWatchRules, resolveGroupAccess, } from './group-access.js';
|
|
11
|
+
import { DEFAULT_AVATAR_BASE64 } from './bot-avatar.js';
|
|
12
|
+
import { Bitrix24ApiError, createVerboseLogger, defaultLogger, CHANNEL_PREFIX_RE } from './utils.js';
|
|
13
|
+
import { getBitrix24Runtime } from './runtime.js';
|
|
14
|
+
import { OPENCLAW_COMMANDS, buildCommandsHelpText, formatModelsCommandReply } from './commands.js';
|
|
15
|
+
import { accessApproved, accessDenied, groupPairingPending, mediaDownloadFailed, groupChatUnsupported, onboardingMessage, ownerAndAllowedUsersOnly, personalBotOwnerOnly, watchOwnerDmNotice, } from './i18n.js';
|
|
16
|
+
import { HistoryCache } from './history-cache.js';
|
|
17
|
+
const PHASE_STATUS_DURATION_SECONDS = 8;
|
|
18
|
+
const PHASE_STATUS_REFRESH_GRACE_MS = 1000;
|
|
19
|
+
const THINKING_STATUS_DURATION_SECONDS = 30;
|
|
20
|
+
const THINKING_STATUS_REFRESH_GRACE_MS = 6000;
|
|
21
|
+
const DIRECT_TEXT_COALESCE_DEBOUNCE_MS = 200;
|
|
22
|
+
const DIRECT_TEXT_COALESCE_MAX_WAIT_MS = 5000;
|
|
23
|
+
const ACCESS_DENIED_NOTICE_COOLDOWN_MS = 60000;
|
|
24
|
+
const AUTO_BOT_CODE_MAX_CANDIDATES = 100;
|
|
25
|
+
const MEDIA_DOWNLOAD_CONCURRENCY = 2;
|
|
26
|
+
const MAX_WEBHOOK_BODY_BYTES = 1024 * 1024;
|
|
27
|
+
const DEFAULT_HISTORY_LIMIT = 100;
|
|
28
|
+
const HISTORY_CACHE_MAX_KEYS = 1000;
|
|
29
|
+
const HISTORY_CONTEXT_MARKER = '[Chat messages since your last reply - for context]';
|
|
30
|
+
const CROSS_CHAT_HISTORY_LIMIT = 20;
|
|
31
|
+
const ACCESS_DENIED_REACTION = 'crossMark';
|
|
32
|
+
const BOT_MESSAGE_WATCH_REACTION = 'eyes';
|
|
33
|
+
const FORWARDED_CONTEXT_RANGE = 5;
|
|
34
|
+
const REGISTERED_COMMANDS = new Set(OPENCLAW_COMMANDS.map((command) => command.command));
|
|
35
|
+
// ─── Emoji → B24 reaction code mapping ──────────────────────────────────
|
|
36
|
+
// B24 uses named reaction codes, not Unicode emoji.
|
|
37
|
+
// Map common Unicode emoji to their B24 equivalents.
|
|
38
|
+
const EMOJI_TO_B24_REACTION = {
|
|
39
|
+
'👍': 'like',
|
|
40
|
+
'👎': 'dislike',
|
|
41
|
+
'😂': 'faceWithTearsOfJoy',
|
|
42
|
+
'❤️': 'redHeart',
|
|
43
|
+
'❤': 'redHeart',
|
|
44
|
+
'😐': 'neutralFace',
|
|
45
|
+
'🔥': 'fire',
|
|
46
|
+
'😢': 'cry',
|
|
47
|
+
'🙂': 'slightlySmilingFace',
|
|
48
|
+
'😉': 'winkingFace',
|
|
49
|
+
'😆': 'laugh',
|
|
50
|
+
'😘': 'kiss',
|
|
51
|
+
'😲': 'wonder',
|
|
52
|
+
'🙁': 'slightlyFrowningFace',
|
|
53
|
+
'😭': 'loudlyCryingFace',
|
|
54
|
+
'😛': 'faceWithStuckOutTongue',
|
|
55
|
+
'😜': 'faceWithStuckOutTongueAndWinkingEye',
|
|
56
|
+
'😎': 'smilingFaceWithSunglasses',
|
|
57
|
+
'😕': 'confusedFace',
|
|
58
|
+
'😳': 'flushedFace',
|
|
59
|
+
'🤔': 'thinkingFace',
|
|
60
|
+
'😠': 'angry',
|
|
61
|
+
'😈': 'smilingFaceWithHorns',
|
|
62
|
+
'🤒': 'faceWithThermometer',
|
|
63
|
+
'🤦': 'facepalm',
|
|
64
|
+
'💩': 'poo',
|
|
65
|
+
'💪': 'flexedBiceps',
|
|
66
|
+
'👏': 'clappingHands',
|
|
67
|
+
'🖐️': 'raisedHand',
|
|
68
|
+
'🖐': 'raisedHand',
|
|
69
|
+
'😍': 'smilingFaceWithHeartEyes',
|
|
70
|
+
'🥰': 'smilingFaceWithHearts',
|
|
71
|
+
'🥺': 'pleadingFace',
|
|
72
|
+
'😌': 'relievedFace',
|
|
73
|
+
'🙏': 'foldedHands',
|
|
74
|
+
'👌': 'okHand',
|
|
75
|
+
'🤘': 'signHorns',
|
|
76
|
+
'🤟': 'loveYouGesture',
|
|
77
|
+
'🤡': 'clownFace',
|
|
78
|
+
'🥳': 'partyingFace',
|
|
79
|
+
'❓': 'questionMark',
|
|
80
|
+
'❗': 'exclamationMark',
|
|
81
|
+
'💡': 'lightBulb',
|
|
82
|
+
'💣': 'bomb',
|
|
83
|
+
'💤': 'sleepingSymbol',
|
|
84
|
+
'❌': 'crossMark',
|
|
85
|
+
'✅': 'whiteHeavyCheckMark',
|
|
86
|
+
'👀': 'eyes',
|
|
87
|
+
'🤝': 'handshake',
|
|
88
|
+
'💯': 'hundredPoints',
|
|
89
|
+
};
|
|
90
|
+
// All valid B24 reaction codes (for pass-through when code is used directly)
|
|
91
|
+
const B24_REACTION_CODES = new Set(Object.values(EMOJI_TO_B24_REACTION));
|
|
92
|
+
/**
|
|
93
|
+
* Resolve an emoji or B24 reaction code to a valid B24 reaction code.
|
|
94
|
+
* Returns null if the emoji/code is not supported.
|
|
95
|
+
*/
|
|
96
|
+
function resolveB24Reaction(emojiOrCode) {
|
|
97
|
+
const trimmed = emojiOrCode.trim();
|
|
98
|
+
if (B24_REACTION_CODES.has(trimmed))
|
|
99
|
+
return trimmed;
|
|
100
|
+
return EMOJI_TO_B24_REACTION[trimmed] ?? null;
|
|
101
|
+
}
|
|
102
|
+
function toMessageId(value) {
|
|
103
|
+
const parsed = Number(value);
|
|
104
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
|
|
105
|
+
}
|
|
106
|
+
function escapeBbCodeText(value) {
|
|
107
|
+
return value
|
|
108
|
+
.replace(/\[/g, '(')
|
|
109
|
+
.replace(/\]/g, ')');
|
|
110
|
+
}
|
|
111
|
+
function buildChatContextUrl(dialogId, messageId, chatName) {
|
|
112
|
+
const dialogParam = encodeURIComponent(dialogId);
|
|
113
|
+
const messageParam = encodeURIComponent(messageId);
|
|
114
|
+
const label = escapeBbCodeText(chatName);
|
|
115
|
+
return `[URL=/online/?IM_DIALOG=${dialogParam}&IM_MESSAGE=${messageParam}]${label}[/URL]`;
|
|
116
|
+
}
|
|
117
|
+
function buildTopicsBbCode(topics) {
|
|
118
|
+
if (!topics || topics.length === 0) {
|
|
119
|
+
return undefined;
|
|
120
|
+
}
|
|
121
|
+
return topics
|
|
122
|
+
.map((topic) => topic.trim())
|
|
123
|
+
.filter(Boolean)
|
|
124
|
+
.map((topic) => `[b]${escapeBbCodeText(topic)}[/b]`)
|
|
125
|
+
.join(', ');
|
|
126
|
+
}
|
|
127
|
+
function formatQuoteTimestamp(timestamp, language) {
|
|
128
|
+
const locale = (language ?? 'ru').toLowerCase().slice(0, 2);
|
|
129
|
+
const value = timestamp ?? Date.now();
|
|
130
|
+
try {
|
|
131
|
+
return new Intl.DateTimeFormat(locale, {
|
|
132
|
+
year: 'numeric',
|
|
133
|
+
month: '2-digit',
|
|
134
|
+
day: '2-digit',
|
|
135
|
+
hour: '2-digit',
|
|
136
|
+
minute: '2-digit',
|
|
137
|
+
}).format(new Date(value)).replace(',', '');
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
return new Intl.DateTimeFormat('ru', {
|
|
141
|
+
year: 'numeric',
|
|
142
|
+
month: '2-digit',
|
|
143
|
+
day: '2-digit',
|
|
144
|
+
hour: '2-digit',
|
|
145
|
+
minute: '2-digit',
|
|
146
|
+
}).format(new Date(value)).replace(',', '');
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
function buildWatchQuoteText(params) {
|
|
150
|
+
const separator = '------------------------------------------------------';
|
|
151
|
+
const senderLine = `${escapeBbCodeText(params.senderName)} [${formatQuoteTimestamp(params.timestamp, params.language)}] ${params.anchor}`;
|
|
152
|
+
const body = escapeBbCodeText(params.body);
|
|
153
|
+
return [
|
|
154
|
+
separator,
|
|
155
|
+
senderLine,
|
|
156
|
+
body,
|
|
157
|
+
separator,
|
|
158
|
+
].join('\n');
|
|
159
|
+
}
|
|
160
|
+
async function notifyStatus(sendService, sendCtx, config, statusMessageCode, duration = PHASE_STATUS_DURATION_SECONDS) {
|
|
161
|
+
if (config.showTyping === false)
|
|
162
|
+
return;
|
|
163
|
+
await sendService.sendStatus(sendCtx, statusMessageCode, duration);
|
|
164
|
+
}
|
|
165
|
+
function createReplyStatusHeartbeat(params) {
|
|
166
|
+
const { sendService, sendCtx, config } = params;
|
|
167
|
+
let timer = null;
|
|
168
|
+
let stopped = false;
|
|
169
|
+
let inFlight = false;
|
|
170
|
+
let activeTick = null;
|
|
171
|
+
let nextHeartbeatAt = Date.now();
|
|
172
|
+
const scheduleNext = () => {
|
|
173
|
+
if (stopped || config.showTyping === false)
|
|
174
|
+
return;
|
|
175
|
+
if (timer) {
|
|
176
|
+
clearTimeout(timer);
|
|
177
|
+
timer = null;
|
|
178
|
+
}
|
|
179
|
+
const delay = Math.max(0, nextHeartbeatAt - Date.now());
|
|
180
|
+
timer = setTimeout(() => {
|
|
181
|
+
void runTick();
|
|
182
|
+
}, delay);
|
|
183
|
+
};
|
|
184
|
+
const holdFor = (durationSeconds, graceMs = PHASE_STATUS_REFRESH_GRACE_MS) => {
|
|
185
|
+
const holdMs = Math.max(0, (durationSeconds * 1000) - graceMs);
|
|
186
|
+
const holdUntil = Date.now() + holdMs;
|
|
187
|
+
if (holdUntil > nextHeartbeatAt) {
|
|
188
|
+
nextHeartbeatAt = holdUntil;
|
|
189
|
+
}
|
|
190
|
+
if (timer) {
|
|
191
|
+
scheduleNext();
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
const runTick = () => {
|
|
195
|
+
const tick = (async () => {
|
|
196
|
+
if (stopped || inFlight || config.showTyping === false)
|
|
197
|
+
return;
|
|
198
|
+
if (Date.now() < nextHeartbeatAt) {
|
|
199
|
+
scheduleNext();
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
inFlight = true;
|
|
203
|
+
try {
|
|
204
|
+
await notifyStatus(sendService, sendCtx, config, 'IMBOT_AGENT_ACTION_THINKING', THINKING_STATUS_DURATION_SECONDS);
|
|
205
|
+
}
|
|
206
|
+
finally {
|
|
207
|
+
inFlight = false;
|
|
208
|
+
nextHeartbeatAt = Date.now() + ((THINKING_STATUS_DURATION_SECONDS * 1000) - THINKING_STATUS_REFRESH_GRACE_MS);
|
|
209
|
+
scheduleNext();
|
|
210
|
+
}
|
|
211
|
+
})();
|
|
212
|
+
activeTick = tick;
|
|
213
|
+
void tick.finally(() => {
|
|
214
|
+
if (activeTick === tick) {
|
|
215
|
+
activeTick = null;
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
return tick;
|
|
219
|
+
};
|
|
220
|
+
return {
|
|
221
|
+
start: async () => {
|
|
222
|
+
if (stopped || timer || config.showTyping === false)
|
|
223
|
+
return;
|
|
224
|
+
if (Date.now() >= nextHeartbeatAt) {
|
|
225
|
+
await runTick();
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
scheduleNext();
|
|
229
|
+
},
|
|
230
|
+
stop: () => {
|
|
231
|
+
stopped = true;
|
|
232
|
+
if (!timer)
|
|
233
|
+
return;
|
|
234
|
+
clearTimeout(timer);
|
|
235
|
+
timer = null;
|
|
236
|
+
},
|
|
237
|
+
stopAndWait: async () => {
|
|
238
|
+
stopped = true;
|
|
239
|
+
if (timer) {
|
|
240
|
+
clearTimeout(timer);
|
|
241
|
+
timer = null;
|
|
242
|
+
}
|
|
243
|
+
if (activeTick) {
|
|
244
|
+
await activeTick;
|
|
245
|
+
}
|
|
246
|
+
},
|
|
247
|
+
holdFor,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
export function canCoalesceDirectMessage(msgCtx, config) {
|
|
251
|
+
return msgCtx.isDm
|
|
252
|
+
&& config.dmPolicy !== 'pairing'
|
|
253
|
+
&& msgCtx.media.length === 0
|
|
254
|
+
&& !msgCtx.replyToMessageId
|
|
255
|
+
&& !msgCtx.isForwarded
|
|
256
|
+
&& msgCtx.text.trim().length > 0
|
|
257
|
+
&& !msgCtx.text.trim().startsWith('/');
|
|
258
|
+
}
|
|
259
|
+
export function mergeBufferedDirectMessages(messages) {
|
|
260
|
+
const first = messages[0];
|
|
261
|
+
const last = messages[messages.length - 1];
|
|
262
|
+
return {
|
|
263
|
+
...first,
|
|
264
|
+
text: messages
|
|
265
|
+
.map((message) => message.text.trim())
|
|
266
|
+
.filter(Boolean)
|
|
267
|
+
.join('\n'),
|
|
268
|
+
messageId: last.messageId,
|
|
269
|
+
language: last.language ?? first.language,
|
|
270
|
+
raw: last.raw,
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
export function mergeForwardedMessageContext(previousMsgCtx, forwardedMsgCtx) {
|
|
274
|
+
const previousText = previousMsgCtx.text.trim();
|
|
275
|
+
const forwardedText = forwardedMsgCtx.text.trim();
|
|
276
|
+
const mergedText = [
|
|
277
|
+
previousText ? '[User question about the forwarded message]' : '',
|
|
278
|
+
previousText,
|
|
279
|
+
previousText ? '[/User question]' : '',
|
|
280
|
+
forwardedText,
|
|
281
|
+
].filter(Boolean).join('\n\n');
|
|
282
|
+
return {
|
|
283
|
+
...forwardedMsgCtx,
|
|
284
|
+
text: mergedText,
|
|
285
|
+
media: [...previousMsgCtx.media, ...forwardedMsgCtx.media],
|
|
286
|
+
language: forwardedMsgCtx.language ?? previousMsgCtx.language,
|
|
287
|
+
replyToMessageId: undefined,
|
|
288
|
+
isForwarded: false,
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
export function resolveDirectMessageCoalesceDelay(params) {
|
|
292
|
+
const debounceMs = params.debounceMs ?? DIRECT_TEXT_COALESCE_DEBOUNCE_MS;
|
|
293
|
+
const maxWaitMs = params.maxWaitMs ?? DIRECT_TEXT_COALESCE_MAX_WAIT_MS;
|
|
294
|
+
const elapsedMs = Math.max(0, params.now - params.startedAt);
|
|
295
|
+
const remainingMs = Math.max(0, maxWaitMs - elapsedMs);
|
|
296
|
+
return Math.min(debounceMs, remainingMs);
|
|
297
|
+
}
|
|
298
|
+
export function shouldSkipJoinChatWelcome(params) {
|
|
299
|
+
if (!params.dialogId) {
|
|
300
|
+
return false;
|
|
301
|
+
}
|
|
302
|
+
if (params.chatType === 'chat' || params.chatType === 'open') {
|
|
303
|
+
return false;
|
|
304
|
+
}
|
|
305
|
+
const policy = params.dmPolicy ?? 'webhookUser';
|
|
306
|
+
const webhookUserId = getWebhookUserId(params.webhookUrl);
|
|
307
|
+
return policy === 'webhookUser'
|
|
308
|
+
&& Boolean(webhookUserId)
|
|
309
|
+
&& normalizeAllowEntry(params.dialogId) !== webhookUserId;
|
|
310
|
+
}
|
|
311
|
+
async function mapWithConcurrency(items, concurrency, worker) {
|
|
312
|
+
if (items.length === 0) {
|
|
313
|
+
return [];
|
|
314
|
+
}
|
|
315
|
+
const results = new Array(items.length);
|
|
316
|
+
let nextIndex = 0;
|
|
317
|
+
const poolSize = Math.max(1, Math.min(concurrency, items.length));
|
|
318
|
+
const runners = Array.from({ length: poolSize }, async () => {
|
|
319
|
+
while (nextIndex < items.length) {
|
|
320
|
+
const currentIndex = nextIndex;
|
|
321
|
+
nextIndex += 1;
|
|
322
|
+
results[currentIndex] = await worker(items[currentIndex], currentIndex);
|
|
323
|
+
}
|
|
324
|
+
});
|
|
325
|
+
await Promise.all(runners);
|
|
326
|
+
return results;
|
|
327
|
+
}
|
|
328
|
+
function resolveSecurityConfig(params) {
|
|
329
|
+
if (params.account?.config) {
|
|
330
|
+
return params.account.config;
|
|
331
|
+
}
|
|
332
|
+
if (params.cfg) {
|
|
333
|
+
return resolveAccount(params.cfg, params.accountId).config;
|
|
334
|
+
}
|
|
335
|
+
return {};
|
|
336
|
+
}
|
|
337
|
+
function resolveHistoryLimit(config) {
|
|
338
|
+
return Math.max(0, config.historyLimit ?? DEFAULT_HISTORY_LIMIT);
|
|
339
|
+
}
|
|
340
|
+
export function resolveConversationRef(params) {
|
|
341
|
+
const dialogId = String(params.dialogId);
|
|
342
|
+
return {
|
|
343
|
+
dialogId,
|
|
344
|
+
address: `bitrix24:${dialogId}`,
|
|
345
|
+
historyKey: `${params.accountId}:${dialogId}`,
|
|
346
|
+
peer: {
|
|
347
|
+
kind: params.isDirect ? 'direct' : 'group',
|
|
348
|
+
id: dialogId,
|
|
349
|
+
},
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
export function buildConversationSessionKey(routeSessionKey, conversation) {
|
|
353
|
+
return `${routeSessionKey}:${conversation.address}`;
|
|
354
|
+
}
|
|
355
|
+
function buildHistoryBody(msgCtx) {
|
|
356
|
+
const text = msgCtx.text.trim();
|
|
357
|
+
if (text) {
|
|
358
|
+
return text;
|
|
359
|
+
}
|
|
360
|
+
if (msgCtx.media.length === 0) {
|
|
361
|
+
return '';
|
|
362
|
+
}
|
|
363
|
+
const hasImage = msgCtx.media.some((mediaItem) => mediaItem.type === 'image');
|
|
364
|
+
return hasImage ? '<media:image>' : '<media:document>';
|
|
365
|
+
}
|
|
366
|
+
function buildConversationMeta(msgCtx) {
|
|
367
|
+
return {
|
|
368
|
+
dialogId: msgCtx.chatId,
|
|
369
|
+
chatId: msgCtx.chatInternalId,
|
|
370
|
+
chatName: msgCtx.chatName,
|
|
371
|
+
chatType: msgCtx.chatType,
|
|
372
|
+
isGroup: msgCtx.isGroup,
|
|
373
|
+
lastActivityAt: msgCtx.timestamp ?? Date.now(),
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
function appendMessageToHistory(params) {
|
|
377
|
+
const historyBody = (params.body ?? buildHistoryBody(params.msgCtx)).trim();
|
|
378
|
+
if (!historyBody) {
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
params.historyCache.append({
|
|
382
|
+
key: params.historyKey,
|
|
383
|
+
limit: params.historyLimit,
|
|
384
|
+
entry: {
|
|
385
|
+
messageId: params.msgCtx.messageId,
|
|
386
|
+
sender: params.msgCtx.senderName,
|
|
387
|
+
senderId: params.msgCtx.senderId,
|
|
388
|
+
body: historyBody,
|
|
389
|
+
timestamp: params.msgCtx.timestamp ?? Date.now(),
|
|
390
|
+
wasMentioned: params.msgCtx.wasMentioned,
|
|
391
|
+
eventScope: params.msgCtx.eventScope ?? 'bot',
|
|
392
|
+
},
|
|
393
|
+
meta: buildConversationMeta(params.msgCtx),
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
function formatHistoryEntry(params) {
|
|
397
|
+
const idSuffix = params.messageId ? ` [id:${params.messageId}]` : '';
|
|
398
|
+
return `[${params.sender}] ${params.body}${idSuffix}`;
|
|
399
|
+
}
|
|
400
|
+
function buildHistoryContext(params) {
|
|
401
|
+
if (params.entries.length === 0) {
|
|
402
|
+
return params.currentBody;
|
|
403
|
+
}
|
|
404
|
+
const historyText = params.entries
|
|
405
|
+
.map((entry) => formatHistoryEntry(entry))
|
|
406
|
+
.join('\n');
|
|
407
|
+
return [
|
|
408
|
+
HISTORY_CONTEXT_MARKER,
|
|
409
|
+
historyText,
|
|
410
|
+
'',
|
|
411
|
+
params.currentBody,
|
|
412
|
+
].join('\n');
|
|
413
|
+
}
|
|
414
|
+
function formatReplyContext(params) {
|
|
415
|
+
if (!params.replyEntry) {
|
|
416
|
+
return params.body;
|
|
417
|
+
}
|
|
418
|
+
return [
|
|
419
|
+
params.body,
|
|
420
|
+
'',
|
|
421
|
+
`[Replying to ${params.replyEntry.sender} id:${params.replyEntry.messageId}]`,
|
|
422
|
+
params.replyEntry.body,
|
|
423
|
+
'[/Replying]',
|
|
424
|
+
].filter(Boolean).join('\n');
|
|
425
|
+
}
|
|
426
|
+
function resolveFetchedUserName(usersById, authorId) {
|
|
427
|
+
return usersById.get(authorId)?.name?.trim() || `User ${authorId}`;
|
|
428
|
+
}
|
|
429
|
+
function resolveFetchedMessageBody(text) {
|
|
430
|
+
const trimmed = text.trim();
|
|
431
|
+
return trimmed.length > 0 ? trimmed : '<empty message>';
|
|
432
|
+
}
|
|
433
|
+
function formatFetchedForwardContext(params) {
|
|
434
|
+
const usersById = new Map(params.context.users.map((user) => [user.id, user]));
|
|
435
|
+
const lines = params.context.messages
|
|
436
|
+
.filter((message) => message.id !== params.currentMessageId)
|
|
437
|
+
.map((message) => {
|
|
438
|
+
const sender = resolveFetchedUserName(usersById, message.authorId);
|
|
439
|
+
const body = resolveFetchedMessageBody(message.text);
|
|
440
|
+
return `[${sender} id:${message.id}] ${body}`;
|
|
441
|
+
})
|
|
442
|
+
.filter(Boolean);
|
|
443
|
+
if (lines.length === 0) {
|
|
444
|
+
return '';
|
|
445
|
+
}
|
|
446
|
+
return [
|
|
447
|
+
'[Bitrix24 surrounding context around this forwarded message - not the forwarded message body]',
|
|
448
|
+
...lines,
|
|
449
|
+
'[/Bitrix24 surrounding context]',
|
|
450
|
+
].join('\n');
|
|
451
|
+
}
|
|
452
|
+
function extractMentionedChatIds(text) {
|
|
453
|
+
const matches = [...text.matchAll(/\[CHAT=(?:chat)?(\d+)(?:[^\]]*)\][\s\S]*?\[\/CHAT\]/gi)];
|
|
454
|
+
return [...new Set(matches.map((match) => match[1]).filter(Boolean))];
|
|
455
|
+
}
|
|
456
|
+
function formatReferencedGroupHistory(params) {
|
|
457
|
+
const chatId = params.conversation.chatId ?? params.conversation.dialogId.replace(/^chat/i, '');
|
|
458
|
+
const chatName = params.conversation.chatName ?? params.conversation.dialogId;
|
|
459
|
+
const header = `[Visible group chat history: [CHAT=${chatId}]${chatName}[/CHAT]]`;
|
|
460
|
+
if (params.entries.length === 0) {
|
|
461
|
+
return [
|
|
462
|
+
header,
|
|
463
|
+
'No messages from this chat are currently available in RAM memory.',
|
|
464
|
+
].join('\n');
|
|
465
|
+
}
|
|
466
|
+
return [
|
|
467
|
+
header,
|
|
468
|
+
...params.entries.map((entry) => formatHistoryEntry(entry)),
|
|
469
|
+
].join('\n');
|
|
470
|
+
}
|
|
471
|
+
function buildCrossChatMemoryContext(params) {
|
|
472
|
+
const chatMentions = extractMentionedChatIds(params.query);
|
|
473
|
+
if (chatMentions.length === 0) {
|
|
474
|
+
return undefined;
|
|
475
|
+
}
|
|
476
|
+
const visibleGroupChats = params.historyCache
|
|
477
|
+
.listConversations()
|
|
478
|
+
.filter((conversation) => conversation.isGroup)
|
|
479
|
+
.sort((left, right) => (right.lastActivityAt ?? 0) - (left.lastActivityAt ?? 0));
|
|
480
|
+
const referencedChats = visibleGroupChats.filter((conversation) => {
|
|
481
|
+
const chatId = conversation.chatId ?? '';
|
|
482
|
+
return chatMentions.includes(chatId);
|
|
483
|
+
});
|
|
484
|
+
const sections = [];
|
|
485
|
+
if (referencedChats.length > 0) {
|
|
486
|
+
sections.push(...referencedChats.map((conversation) => formatReferencedGroupHistory({
|
|
487
|
+
conversation,
|
|
488
|
+
entries: params.historyCache.get(conversation.key, CROSS_CHAT_HISTORY_LIMIT),
|
|
489
|
+
})));
|
|
490
|
+
}
|
|
491
|
+
else if (chatMentions.length > 0) {
|
|
492
|
+
sections.push('[Referenced group chats]\nThe requested group chat mention is not available in RAM memory right now.');
|
|
493
|
+
}
|
|
494
|
+
return [
|
|
495
|
+
'[OpenClaw cross-chat memory]',
|
|
496
|
+
'The following Bitrix24 group chat memory is already available to you from RAM history.',
|
|
497
|
+
'Use it as trusted context for your answer.',
|
|
498
|
+
'Do not say that you only see the current chat if chats or messages are listed below.',
|
|
499
|
+
'Do not call tools to list chats when this memory block already contains the answer.',
|
|
500
|
+
'',
|
|
501
|
+
'[BEGIN OPENCLAW CROSS-CHAT MEMORY]',
|
|
502
|
+
sections.join('\n\n'),
|
|
503
|
+
'[END OPENCLAW CROSS-CHAT MEMORY]',
|
|
504
|
+
].join('\n');
|
|
505
|
+
}
|
|
506
|
+
function buildAccessDeniedNotice(lang, policy, params) {
|
|
507
|
+
const effectivePolicy = policy ?? 'webhookUser';
|
|
508
|
+
if (effectivePolicy === 'webhookUser') {
|
|
509
|
+
return personalBotOwnerOnly(lang);
|
|
510
|
+
}
|
|
511
|
+
if (params?.hasAllowList) {
|
|
512
|
+
return ownerAndAllowedUsersOnly(lang);
|
|
513
|
+
}
|
|
514
|
+
return accessDenied(lang);
|
|
515
|
+
}
|
|
516
|
+
function normalizeTopicText(text) {
|
|
517
|
+
return text.toLowerCase().replace(/\s+/g, ' ').trim();
|
|
518
|
+
}
|
|
519
|
+
function tokenizeTopicText(text) {
|
|
520
|
+
return normalizeTopicText(text)
|
|
521
|
+
.split(/[^a-zа-яё0-9]+/i)
|
|
522
|
+
.filter(Boolean);
|
|
523
|
+
}
|
|
524
|
+
function matchesWatchTopic(messageText, topic) {
|
|
525
|
+
const normalizedMessage = normalizeTopicText(messageText);
|
|
526
|
+
const normalizedTopic = normalizeTopicText(topic);
|
|
527
|
+
if (!normalizedMessage || !normalizedTopic) {
|
|
528
|
+
return false;
|
|
529
|
+
}
|
|
530
|
+
if (normalizedMessage.includes(normalizedTopic)) {
|
|
531
|
+
return true;
|
|
532
|
+
}
|
|
533
|
+
const messageTokens = tokenizeTopicText(messageText);
|
|
534
|
+
const topicTokens = tokenizeTopicText(topic);
|
|
535
|
+
return topicTokens.length > 0
|
|
536
|
+
&& topicTokens.every((topicToken) => messageTokens.some((messageToken) => messageToken.startsWith(topicToken)));
|
|
537
|
+
}
|
|
538
|
+
function findMatchingWatchRule(msgCtx, watchRules) {
|
|
539
|
+
if (!Array.isArray(watchRules) || watchRules.length === 0) {
|
|
540
|
+
return undefined;
|
|
541
|
+
}
|
|
542
|
+
const senderId = normalizeAllowEntry(msgCtx.senderId);
|
|
543
|
+
return watchRules.find((rule) => {
|
|
544
|
+
const ruleUserId = normalizeAllowEntry(rule.userId);
|
|
545
|
+
if (ruleUserId !== '*' && ruleUserId !== senderId) {
|
|
546
|
+
return false;
|
|
547
|
+
}
|
|
548
|
+
if (!Array.isArray(rule.topics) || rule.topics.length === 0) {
|
|
549
|
+
return true;
|
|
550
|
+
}
|
|
551
|
+
return rule.topics.some((topic) => matchesWatchTopic(msgCtx.text, topic));
|
|
552
|
+
});
|
|
553
|
+
}
|
|
554
|
+
class BufferedDirectMessageCoalescer {
|
|
555
|
+
entries = new Map();
|
|
556
|
+
debounceMs;
|
|
557
|
+
maxWaitMs;
|
|
558
|
+
onFlush;
|
|
559
|
+
logger;
|
|
560
|
+
destroyed = false;
|
|
561
|
+
constructor(params) {
|
|
562
|
+
this.debounceMs = params.debounceMs;
|
|
563
|
+
this.maxWaitMs = params.maxWaitMs;
|
|
564
|
+
this.onFlush = params.onFlush;
|
|
565
|
+
this.logger = params.logger;
|
|
566
|
+
}
|
|
567
|
+
enqueue(accountId, msgCtx) {
|
|
568
|
+
const key = this.getKey(accountId, msgCtx.chatId);
|
|
569
|
+
const current = this.entries.get(key);
|
|
570
|
+
if (current) {
|
|
571
|
+
clearTimeout(current.timer);
|
|
572
|
+
current.messages.push(msgCtx);
|
|
573
|
+
current.timer = this.createTimer(key, current.startedAt);
|
|
574
|
+
this.logger.debug('Buffered direct message appended', {
|
|
575
|
+
chatId: msgCtx.chatId,
|
|
576
|
+
bufferedCount: current.messages.length,
|
|
577
|
+
});
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
const startedAt = Date.now();
|
|
581
|
+
this.entries.set(key, {
|
|
582
|
+
messages: [msgCtx],
|
|
583
|
+
startedAt,
|
|
584
|
+
timer: this.createTimer(key, startedAt),
|
|
585
|
+
});
|
|
586
|
+
this.logger.debug('Buffered direct message started', { chatId: msgCtx.chatId });
|
|
587
|
+
}
|
|
588
|
+
async flush(accountId, dialogId) {
|
|
589
|
+
await this.flushKey(this.getKey(accountId, dialogId));
|
|
590
|
+
}
|
|
591
|
+
take(accountId, dialogId) {
|
|
592
|
+
const key = this.getKey(accountId, dialogId);
|
|
593
|
+
const entry = this.entries.get(key);
|
|
594
|
+
if (!entry)
|
|
595
|
+
return null;
|
|
596
|
+
clearTimeout(entry.timer);
|
|
597
|
+
this.entries.delete(key);
|
|
598
|
+
this.logger.debug('Taking buffered direct messages', {
|
|
599
|
+
chatId: dialogId,
|
|
600
|
+
bufferedCount: entry.messages.length,
|
|
601
|
+
});
|
|
602
|
+
return entry.messages.length === 1
|
|
603
|
+
? entry.messages[0]
|
|
604
|
+
: mergeBufferedDirectMessages(entry.messages);
|
|
605
|
+
}
|
|
606
|
+
async flushAll() {
|
|
607
|
+
for (const key of [...this.entries.keys()]) {
|
|
608
|
+
await this.flushKey(key);
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
destroy() {
|
|
612
|
+
this.destroyed = true;
|
|
613
|
+
for (const entry of this.entries.values()) {
|
|
614
|
+
clearTimeout(entry.timer);
|
|
615
|
+
}
|
|
616
|
+
this.entries.clear();
|
|
617
|
+
}
|
|
618
|
+
getKey(accountId, dialogId) {
|
|
619
|
+
return `${accountId}:${dialogId}`;
|
|
620
|
+
}
|
|
621
|
+
createTimer(key, startedAt) {
|
|
622
|
+
const delayMs = resolveDirectMessageCoalesceDelay({
|
|
623
|
+
startedAt,
|
|
624
|
+
now: Date.now(),
|
|
625
|
+
debounceMs: this.debounceMs,
|
|
626
|
+
maxWaitMs: this.maxWaitMs,
|
|
627
|
+
});
|
|
628
|
+
return setTimeout(() => {
|
|
629
|
+
void this.flushKey(key);
|
|
630
|
+
}, delayMs);
|
|
631
|
+
}
|
|
632
|
+
async flushKey(key) {
|
|
633
|
+
const entry = this.entries.get(key);
|
|
634
|
+
if (!entry || this.destroyed)
|
|
635
|
+
return;
|
|
636
|
+
clearTimeout(entry.timer);
|
|
637
|
+
this.entries.delete(key);
|
|
638
|
+
const msgCtx = entry.messages.length === 1
|
|
639
|
+
? entry.messages[0]
|
|
640
|
+
: mergeBufferedDirectMessages(entry.messages);
|
|
641
|
+
try {
|
|
642
|
+
this.logger.debug('Flushing buffered direct messages', {
|
|
643
|
+
chatId: msgCtx.chatId,
|
|
644
|
+
bufferedCount: entry.messages.length,
|
|
645
|
+
});
|
|
646
|
+
await this.onFlush(msgCtx);
|
|
647
|
+
}
|
|
648
|
+
catch (err) {
|
|
649
|
+
this.logger.error('Failed to flush buffered direct messages', err);
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
let gatewayState = null;
|
|
654
|
+
export function __setGatewayStateForTests(state) {
|
|
655
|
+
gatewayState = state;
|
|
656
|
+
}
|
|
657
|
+
// ─── Default command keyboard ────────────────────────────────────────────────
|
|
658
|
+
/** Default keyboard shown with command responses and welcome messages. */
|
|
659
|
+
export const DEFAULT_COMMAND_KEYBOARD = [
|
|
660
|
+
{ TEXT: 'Help', COMMAND: 'help', BG_COLOR_TOKEN: 'primary', DISPLAY: 'LINE' },
|
|
661
|
+
{ TEXT: 'Status', COMMAND: 'status', DISPLAY: 'LINE' },
|
|
662
|
+
{ TEXT: 'Commands', COMMAND: 'commands', DISPLAY: 'LINE' },
|
|
663
|
+
{ TYPE: 'NEWLINE' },
|
|
664
|
+
{ TEXT: 'New session', COMMAND: 'new', DISPLAY: 'LINE' },
|
|
665
|
+
{ TEXT: 'Models', COMMAND: 'models', DISPLAY: 'LINE' },
|
|
666
|
+
];
|
|
667
|
+
function parseRegisteredCommandTrigger(callbackData) {
|
|
668
|
+
const trimmed = callbackData.trim();
|
|
669
|
+
const isSlashCommand = trimmed.startsWith('/');
|
|
670
|
+
const normalized = trimmed.replace(/^\/+/, '');
|
|
671
|
+
if (!normalized) {
|
|
672
|
+
return undefined;
|
|
673
|
+
}
|
|
674
|
+
const [commandName, ...params] = normalized.split(/\s+/);
|
|
675
|
+
if (!isSlashCommand && !REGISTERED_COMMANDS.has(commandName)) {
|
|
676
|
+
return undefined;
|
|
677
|
+
}
|
|
678
|
+
return {
|
|
679
|
+
command: commandName,
|
|
680
|
+
...(params.length > 0 ? { commandParams: params.join(' ') } : {}),
|
|
681
|
+
};
|
|
682
|
+
}
|
|
683
|
+
/**
|
|
684
|
+
* Convert OpenClaw button rows to B24 flat KEYBOARD array.
|
|
685
|
+
*/
|
|
686
|
+
export function convertButtonsToKeyboard(input) {
|
|
687
|
+
// Normalize: accept both [[btn, btn], [btn]] (rows) and [btn, btn] (flat)
|
|
688
|
+
const isNested = input.length > 0 && Array.isArray(input[0]);
|
|
689
|
+
const rows = isNested
|
|
690
|
+
? input
|
|
691
|
+
: [input];
|
|
692
|
+
const keyboard = [];
|
|
693
|
+
for (let i = 0; i < rows.length; i++) {
|
|
694
|
+
for (const btn of rows[i]) {
|
|
695
|
+
const b24Btn = { TEXT: btn.text, DISPLAY: 'LINE' };
|
|
696
|
+
const parsedCommand = btn.callback_data
|
|
697
|
+
? parseRegisteredCommandTrigger(btn.callback_data)
|
|
698
|
+
: undefined;
|
|
699
|
+
if (parsedCommand) {
|
|
700
|
+
b24Btn.COMMAND = parsedCommand.command;
|
|
701
|
+
if (parsedCommand.commandParams) {
|
|
702
|
+
b24Btn.COMMAND_PARAMS = parsedCommand.commandParams;
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
else if (btn.callback_data) {
|
|
706
|
+
b24Btn.ACTION = 'SEND';
|
|
707
|
+
// Always use button text as ACTION_VALUE so the user sees readable text in chat,
|
|
708
|
+
// not opaque English identifiers like "answer_piano"
|
|
709
|
+
b24Btn.ACTION_VALUE = btn.text;
|
|
710
|
+
}
|
|
711
|
+
if (btn.style === 'primary') {
|
|
712
|
+
b24Btn.BG_COLOR_TOKEN = 'primary';
|
|
713
|
+
}
|
|
714
|
+
else if (btn.style === 'attention' || btn.style === 'danger') {
|
|
715
|
+
b24Btn.BG_COLOR_TOKEN = 'alert';
|
|
716
|
+
}
|
|
717
|
+
keyboard.push(b24Btn);
|
|
718
|
+
}
|
|
719
|
+
if (i < rows.length - 1) {
|
|
720
|
+
keyboard.push({ TYPE: 'NEWLINE' });
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
return keyboard;
|
|
724
|
+
}
|
|
725
|
+
/**
|
|
726
|
+
* Extract B24 keyboard from a dispatcher payload's channelData.
|
|
727
|
+
*/
|
|
728
|
+
export function extractKeyboardFromPayload(payload) {
|
|
729
|
+
const cd = payload.channelData;
|
|
730
|
+
if (!cd)
|
|
731
|
+
return undefined;
|
|
732
|
+
const b24Data = cd.bitrix24;
|
|
733
|
+
if (b24Data?.keyboard?.length) {
|
|
734
|
+
return b24Data.keyboard;
|
|
735
|
+
}
|
|
736
|
+
const tgData = cd.telegram;
|
|
737
|
+
if (tgData?.buttons?.length) {
|
|
738
|
+
return convertButtonsToKeyboard(tgData.buttons);
|
|
739
|
+
}
|
|
740
|
+
return undefined;
|
|
741
|
+
}
|
|
742
|
+
/**
|
|
743
|
+
* Extract inline button JSON embedded in message text by the agent.
|
|
744
|
+
*
|
|
745
|
+
* Agents (especially GPT-4o) sometimes embed button markup directly in text
|
|
746
|
+
* as `[[{...},{...}]]` instead of using tool call parameters.
|
|
747
|
+
* This function detects such patterns, extracts the keyboard, and returns
|
|
748
|
+
* cleaned text without the JSON fragment.
|
|
749
|
+
*/
|
|
750
|
+
export function extractInlineButtonsFromText(text) {
|
|
751
|
+
// Match [[...]] containing JSON array of button objects
|
|
752
|
+
const match = text.match(/\[\[\s*(\{[\s\S]*?\}(?:\s*,\s*\{[\s\S]*?\})*)\s*\]\]/);
|
|
753
|
+
if (!match)
|
|
754
|
+
return undefined;
|
|
755
|
+
try {
|
|
756
|
+
const parsed = JSON.parse(`[${match[1]}]`);
|
|
757
|
+
if (!Array.isArray(parsed) || parsed.length === 0)
|
|
758
|
+
return undefined;
|
|
759
|
+
// Validate it looks like button objects (must have "text" property)
|
|
760
|
+
if (!parsed.every((b) => typeof b === 'object' && b !== null && 'text' in b))
|
|
761
|
+
return undefined;
|
|
762
|
+
// Wrap in rows array (single row)
|
|
763
|
+
const buttons = [parsed];
|
|
764
|
+
const keyboard = convertButtonsToKeyboard(buttons);
|
|
765
|
+
const cleanText = text.replace(match[0], '').trim();
|
|
766
|
+
return { cleanText, keyboard };
|
|
767
|
+
}
|
|
768
|
+
catch {
|
|
769
|
+
return undefined;
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
function normalizeCommandReplyPayload(params) {
|
|
773
|
+
const { commandName, commandParams, text, language } = params;
|
|
774
|
+
if (commandName === 'models' && commandParams.trim() === '') {
|
|
775
|
+
const formattedText = formatModelsCommandReply(text, language);
|
|
776
|
+
if (formattedText) {
|
|
777
|
+
return { text: formattedText, convertMarkdown: false };
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
return { text };
|
|
781
|
+
}
|
|
782
|
+
/**
|
|
783
|
+
* Determine effective event mode from config.
|
|
784
|
+
*/
|
|
785
|
+
function resolveEventMode(config) {
|
|
786
|
+
return config.eventMode ?? (config.callbackUrl ? 'webhook' : 'fetch');
|
|
787
|
+
}
|
|
788
|
+
/**
|
|
789
|
+
* Generate a stable botToken from webhookUrl if not configured.
|
|
790
|
+
*/
|
|
791
|
+
function resolveBotToken(config) {
|
|
792
|
+
if (config.botToken)
|
|
793
|
+
return config.botToken;
|
|
794
|
+
if (!config.webhookUrl)
|
|
795
|
+
return null;
|
|
796
|
+
// Derive a stable token from webhookUrl (md5, max 32 chars — platform limit)
|
|
797
|
+
return createHash('md5').update(config.webhookUrl).digest('hex');
|
|
798
|
+
}
|
|
799
|
+
export function buildBotCodeCandidates(config, maxCandidates = AUTO_BOT_CODE_MAX_CANDIDATES) {
|
|
800
|
+
if (config.botCode) {
|
|
801
|
+
return [config.botCode];
|
|
802
|
+
}
|
|
803
|
+
const webhookUserId = getWebhookUserId(config.webhookUrl);
|
|
804
|
+
const baseCode = webhookUserId ? `openclaw_${webhookUserId}` : 'openclaw';
|
|
805
|
+
const safeMaxCandidates = Math.max(1, maxCandidates);
|
|
806
|
+
return Array.from({ length: safeMaxCandidates }, (_value, index) => {
|
|
807
|
+
return index === 0 ? baseCode : `${baseCode}_${index + 1}`;
|
|
808
|
+
});
|
|
809
|
+
}
|
|
810
|
+
function isBotCodeAlreadyTakenError(error) {
|
|
811
|
+
return error instanceof Bitrix24ApiError && error.code === 'BOT_CODE_ALREADY_TAKEN';
|
|
812
|
+
}
|
|
813
|
+
function isFreshBotRegistration(bot) {
|
|
814
|
+
return ((bot.countMessage ?? 0) === 0 &&
|
|
815
|
+
(bot.countCommand ?? 0) === 0 &&
|
|
816
|
+
(bot.countChat ?? 0) === 0 &&
|
|
817
|
+
(bot.countUser ?? 0) === 0);
|
|
818
|
+
}
|
|
819
|
+
async function sendInitialWelcomeToWebhookOwner(params) {
|
|
820
|
+
const { config, bot, sendService, language, welcomedDialogs, logger } = params;
|
|
821
|
+
const ownerId = getWebhookUserId(config.webhookUrl);
|
|
822
|
+
if (!ownerId || !config.webhookUrl || welcomedDialogs.has(ownerId)) {
|
|
823
|
+
return;
|
|
824
|
+
}
|
|
825
|
+
const sendCtx = {
|
|
826
|
+
webhookUrl: config.webhookUrl,
|
|
827
|
+
bot,
|
|
828
|
+
dialogId: ownerId,
|
|
829
|
+
};
|
|
830
|
+
const isPairing = config.dmPolicy === 'pairing';
|
|
831
|
+
const text = onboardingMessage(language, config.botName ?? 'OpenClaw', config.dmPolicy);
|
|
832
|
+
const options = isPairing ? undefined : { keyboard: DEFAULT_COMMAND_KEYBOARD };
|
|
833
|
+
try {
|
|
834
|
+
await sendService.sendText(sendCtx, text, options);
|
|
835
|
+
welcomedDialogs.add(ownerId);
|
|
836
|
+
logger.info('Initial welcome sent to webhook owner', {
|
|
837
|
+
dialogId: ownerId,
|
|
838
|
+
language: language ?? 'en',
|
|
839
|
+
});
|
|
840
|
+
}
|
|
841
|
+
catch (err) {
|
|
842
|
+
logger.warn('Failed to send initial welcome to webhook owner', err);
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
/**
|
|
846
|
+
* Register or update the bot using imbot.v2.Bot.register / Bot.update.
|
|
847
|
+
*/
|
|
848
|
+
async function ensureBotRegistered(api, config, botToken, eventMode, logger) {
|
|
849
|
+
const { webhookUrl, callbackUrl, botName } = config;
|
|
850
|
+
if (!webhookUrl)
|
|
851
|
+
return null;
|
|
852
|
+
if (eventMode === 'webhook' && !callbackUrl) {
|
|
853
|
+
logger.warn('callbackUrl not configured for webhook mode — skipping bot registration');
|
|
854
|
+
return null;
|
|
855
|
+
}
|
|
856
|
+
const name = botName ?? 'OpenClaw';
|
|
857
|
+
const botCodeCandidates = buildBotCodeCandidates(config);
|
|
858
|
+
// Check if bot already exists via imbot.v2.Bot.list
|
|
859
|
+
try {
|
|
860
|
+
const listResult = await api.listBots(webhookUrl, botToken);
|
|
861
|
+
const existing = botCodeCandidates
|
|
862
|
+
.map((candidate) => listResult.bots.find((botItem) => botItem.code === candidate))
|
|
863
|
+
.find(Boolean);
|
|
864
|
+
if (existing) {
|
|
865
|
+
logger.info(`Bot "${existing.code}" already registered (ID=${existing.id}), updating`);
|
|
866
|
+
const bot = { botId: existing.id, botToken };
|
|
867
|
+
const updateFields = {
|
|
868
|
+
properties: {
|
|
869
|
+
name,
|
|
870
|
+
workPosition: 'AI Assistant',
|
|
871
|
+
avatar: config.botAvatar || DEFAULT_AVATAR_BASE64,
|
|
872
|
+
},
|
|
873
|
+
eventMode,
|
|
874
|
+
};
|
|
875
|
+
if (eventMode === 'webhook' && callbackUrl) {
|
|
876
|
+
updateFields.webhookUrl = callbackUrl;
|
|
877
|
+
}
|
|
878
|
+
await api.updateBot(webhookUrl, bot, updateFields);
|
|
879
|
+
return {
|
|
880
|
+
botId: existing.id,
|
|
881
|
+
language: existing.language,
|
|
882
|
+
isNew: false,
|
|
883
|
+
};
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
catch (err) {
|
|
887
|
+
logger.warn('Failed to list existing bots, will try to register', err);
|
|
888
|
+
}
|
|
889
|
+
// Register new bot via imbot.v2.Bot.register
|
|
890
|
+
for (const code of botCodeCandidates) {
|
|
891
|
+
try {
|
|
892
|
+
const registerFields = {
|
|
893
|
+
code,
|
|
894
|
+
properties: {
|
|
895
|
+
name,
|
|
896
|
+
workPosition: 'AI Assistant',
|
|
897
|
+
avatar: config.botAvatar || DEFAULT_AVATAR_BASE64,
|
|
898
|
+
},
|
|
899
|
+
type: 'personal',
|
|
900
|
+
eventMode,
|
|
901
|
+
};
|
|
902
|
+
if (eventMode === 'webhook' && callbackUrl) {
|
|
903
|
+
registerFields.webhookUrl = callbackUrl;
|
|
904
|
+
}
|
|
905
|
+
const result = await api.registerBot(webhookUrl, botToken, registerFields);
|
|
906
|
+
logger.info(`Bot "${code}" registered in ${eventMode} mode (ID=${result.bot.id})`);
|
|
907
|
+
return {
|
|
908
|
+
botId: result.bot.id,
|
|
909
|
+
language: result.bot.language,
|
|
910
|
+
isNew: isFreshBotRegistration(result.bot),
|
|
911
|
+
};
|
|
912
|
+
}
|
|
913
|
+
catch (err) {
|
|
914
|
+
if (!config.botCode && isBotCodeAlreadyTakenError(err)) {
|
|
915
|
+
logger.warn(`Bot code "${code}" already taken, trying next candidate`);
|
|
916
|
+
continue;
|
|
917
|
+
}
|
|
918
|
+
logger.error('Failed to register bot', err);
|
|
919
|
+
return null;
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
logger.error('Failed to register bot: exhausted automatic bot code candidates');
|
|
923
|
+
return null;
|
|
924
|
+
}
|
|
925
|
+
/**
|
|
926
|
+
* Register OpenClaw slash commands with imbot.v2.Command.register.
|
|
927
|
+
* V2 Command.register is idempotent and doesn't need EVENT_COMMAND_ADD URL.
|
|
928
|
+
*/
|
|
929
|
+
async function ensureCommandsRegistered(api, config, bot, logger) {
|
|
930
|
+
const { webhookUrl } = config;
|
|
931
|
+
if (!webhookUrl)
|
|
932
|
+
return;
|
|
933
|
+
let registered = 0;
|
|
934
|
+
let skipped = 0;
|
|
935
|
+
for (const cmd of OPENCLAW_COMMANDS) {
|
|
936
|
+
try {
|
|
937
|
+
await api.registerCommand(webhookUrl, bot, {
|
|
938
|
+
command: cmd.command,
|
|
939
|
+
title: { en: cmd.en, ru: cmd.ru },
|
|
940
|
+
...(cmd.params ? { params: { en: cmd.params, ru: cmd.params } } : {}),
|
|
941
|
+
});
|
|
942
|
+
registered++;
|
|
943
|
+
}
|
|
944
|
+
catch (err) {
|
|
945
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
946
|
+
if (msg.includes('WRONG_REQUEST') || msg.includes('already')) {
|
|
947
|
+
skipped++;
|
|
948
|
+
}
|
|
949
|
+
else {
|
|
950
|
+
logger.warn(`Failed to register command /${cmd.command}`, err);
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
logger.info(`Commands sync: ${registered} registered, ${skipped} already existed (total ${OPENCLAW_COMMANDS.length})`);
|
|
955
|
+
}
|
|
956
|
+
/**
|
|
957
|
+
* Handle an incoming HTTP request on the webhook route (V2 webhook mode).
|
|
958
|
+
*/
|
|
959
|
+
export async function handleWebhookRequest(req, res) {
|
|
960
|
+
if (req.method !== 'POST') {
|
|
961
|
+
res.statusCode = 405;
|
|
962
|
+
res.end('Method Not Allowed');
|
|
963
|
+
return;
|
|
964
|
+
}
|
|
965
|
+
if (!gatewayState) {
|
|
966
|
+
res.statusCode = 503;
|
|
967
|
+
res.end('Channel not started');
|
|
968
|
+
return;
|
|
969
|
+
}
|
|
970
|
+
if (gatewayState.eventMode === 'fetch') {
|
|
971
|
+
res.statusCode = 200;
|
|
972
|
+
res.end('FETCH mode active');
|
|
973
|
+
return;
|
|
974
|
+
}
|
|
975
|
+
// Read raw body
|
|
976
|
+
const chunks = [];
|
|
977
|
+
let bodySize = 0;
|
|
978
|
+
for await (const chunk of req) {
|
|
979
|
+
const buffer = typeof chunk === 'string' ? Buffer.from(chunk) : chunk;
|
|
980
|
+
chunks.push(buffer);
|
|
981
|
+
bodySize += buffer.length;
|
|
982
|
+
if (bodySize > MAX_WEBHOOK_BODY_BYTES) {
|
|
983
|
+
res.statusCode = 413;
|
|
984
|
+
res.end('Payload Too Large');
|
|
985
|
+
req.destroy();
|
|
986
|
+
return;
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
const body = Buffer.concat(chunks).toString('utf-8');
|
|
990
|
+
try {
|
|
991
|
+
const handled = await gatewayState.inboundHandler.handleWebhook(body);
|
|
992
|
+
if (!handled) {
|
|
993
|
+
res.statusCode = 400;
|
|
994
|
+
res.end('Invalid webhook payload');
|
|
995
|
+
return;
|
|
996
|
+
}
|
|
997
|
+
res.statusCode = 200;
|
|
998
|
+
res.setHeader('Content-Type', 'application/json');
|
|
999
|
+
res.end(JSON.stringify({ status: 'ok' }));
|
|
1000
|
+
}
|
|
1001
|
+
catch (err) {
|
|
1002
|
+
defaultLogger.error('Error handling Bitrix24 V2 webhook', err);
|
|
1003
|
+
res.statusCode = 500;
|
|
1004
|
+
res.end('Webhook processing failed');
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
// ─── Outbound adapter helpers ────────────────────────────────────────────────
|
|
1008
|
+
function resolveOutboundSendCtx(params) {
|
|
1009
|
+
const { config } = resolveAccount(params.cfg, params.accountId);
|
|
1010
|
+
if (!config.webhookUrl || !gatewayState)
|
|
1011
|
+
return null;
|
|
1012
|
+
return {
|
|
1013
|
+
webhookUrl: config.webhookUrl,
|
|
1014
|
+
bot: gatewayState.bot,
|
|
1015
|
+
dialogId: params.to,
|
|
1016
|
+
};
|
|
1017
|
+
}
|
|
1018
|
+
function collectOutboundMediaUrls(input) {
|
|
1019
|
+
const mediaUrls = [];
|
|
1020
|
+
if (input.mediaUrl) {
|
|
1021
|
+
mediaUrls.push(input.mediaUrl);
|
|
1022
|
+
}
|
|
1023
|
+
if (input.payload?.mediaUrl) {
|
|
1024
|
+
mediaUrls.push(input.payload.mediaUrl);
|
|
1025
|
+
}
|
|
1026
|
+
if (Array.isArray(input.payload?.mediaUrls)) {
|
|
1027
|
+
mediaUrls.push(...input.payload.mediaUrls.filter((item) => typeof item === 'string'));
|
|
1028
|
+
}
|
|
1029
|
+
return [...new Set(mediaUrls)];
|
|
1030
|
+
}
|
|
1031
|
+
async function uploadOutboundMedia(params) {
|
|
1032
|
+
let lastMessageId = '';
|
|
1033
|
+
let message = params.initialMessage;
|
|
1034
|
+
for (const mediaUrl of params.mediaUrls) {
|
|
1035
|
+
const result = await params.mediaService.uploadMediaToChat({
|
|
1036
|
+
localPath: mediaUrl,
|
|
1037
|
+
fileName: basename(mediaUrl),
|
|
1038
|
+
webhookUrl: params.sendCtx.webhookUrl,
|
|
1039
|
+
bot: params.sendCtx.bot,
|
|
1040
|
+
dialogId: params.sendCtx.dialogId,
|
|
1041
|
+
message: message || undefined,
|
|
1042
|
+
});
|
|
1043
|
+
if (!result.ok) {
|
|
1044
|
+
throw new Error(`Failed to upload media: ${basename(mediaUrl)}`);
|
|
1045
|
+
}
|
|
1046
|
+
if (result.messageId) {
|
|
1047
|
+
lastMessageId = String(result.messageId);
|
|
1048
|
+
}
|
|
1049
|
+
message = undefined;
|
|
1050
|
+
}
|
|
1051
|
+
return lastMessageId;
|
|
1052
|
+
}
|
|
1053
|
+
/**
|
|
1054
|
+
* The Bitrix24 channel plugin object.
|
|
1055
|
+
*/
|
|
1056
|
+
export const bitrix24Plugin = {
|
|
1057
|
+
id: 'bitrix24',
|
|
1058
|
+
meta: {
|
|
1059
|
+
id: 'bitrix24',
|
|
1060
|
+
label: 'Bitrix24',
|
|
1061
|
+
selectionLabel: 'Bitrix24 (Messenger)',
|
|
1062
|
+
docsPath: '/channels/bitrix24',
|
|
1063
|
+
docsLabel: 'bitrix24',
|
|
1064
|
+
blurb: 'Connect to Bitrix24 Messenger via chat bot REST API (V2).',
|
|
1065
|
+
aliases: ['b24', 'bx24'],
|
|
1066
|
+
},
|
|
1067
|
+
capabilities: {
|
|
1068
|
+
chatTypes: ['direct', 'group'],
|
|
1069
|
+
media: true,
|
|
1070
|
+
reactions: true,
|
|
1071
|
+
threads: false,
|
|
1072
|
+
nativeCommands: true,
|
|
1073
|
+
inlineButtons: 'all',
|
|
1074
|
+
},
|
|
1075
|
+
messaging: {
|
|
1076
|
+
normalizeTarget: (raw) => raw.trim().replace(CHANNEL_PREFIX_RE, ''),
|
|
1077
|
+
targetResolver: {
|
|
1078
|
+
hint: 'Use a numeric chat/dialog ID, e.g. "1" or "chat42".',
|
|
1079
|
+
looksLikeId: (raw, _normalized) => {
|
|
1080
|
+
const stripped = raw.trim().replace(CHANNEL_PREFIX_RE, '');
|
|
1081
|
+
return /^\d+$/.test(stripped);
|
|
1082
|
+
},
|
|
1083
|
+
},
|
|
1084
|
+
},
|
|
1085
|
+
config: {
|
|
1086
|
+
listAccountIds: (cfg) => listAccountIds(cfg),
|
|
1087
|
+
resolveAccount: (cfg, accountId) => resolveAccount(cfg, accountId),
|
|
1088
|
+
},
|
|
1089
|
+
security: {
|
|
1090
|
+
resolveDmPolicy: (params) => {
|
|
1091
|
+
const securityConfig = resolveSecurityConfig(params);
|
|
1092
|
+
const policy = securityConfig.dmPolicy ?? 'webhookUser';
|
|
1093
|
+
return {
|
|
1094
|
+
policy,
|
|
1095
|
+
allowFrom: normalizeAllowList(securityConfig.allowFrom),
|
|
1096
|
+
policyPath: 'channels.bitrix24.dmPolicy',
|
|
1097
|
+
allowFromPath: 'channels.bitrix24.allowFrom',
|
|
1098
|
+
approveHint: 'openclaw pairing approve bitrix24 <CODE>',
|
|
1099
|
+
normalizeEntry: (raw) => raw.replace(CHANNEL_PREFIX_RE, ''),
|
|
1100
|
+
};
|
|
1101
|
+
},
|
|
1102
|
+
normalizeAllowFrom: (entry) => entry.replace(CHANNEL_PREFIX_RE, ''),
|
|
1103
|
+
},
|
|
1104
|
+
pairing: {
|
|
1105
|
+
idLabel: 'bitrix24UserId',
|
|
1106
|
+
normalizeAllowEntry: (entry) => normalizeAllowEntry(entry),
|
|
1107
|
+
notifyApproval: async (params) => {
|
|
1108
|
+
const { config: acctCfg } = resolveAccount(params.cfg);
|
|
1109
|
+
if (!acctCfg.webhookUrl || !gatewayState)
|
|
1110
|
+
return;
|
|
1111
|
+
const sendCtx = {
|
|
1112
|
+
webhookUrl: acctCfg.webhookUrl,
|
|
1113
|
+
bot: gatewayState.bot,
|
|
1114
|
+
dialogId: params.id,
|
|
1115
|
+
};
|
|
1116
|
+
try {
|
|
1117
|
+
await gatewayState.sendService.sendText(sendCtx, `\u2705 ${accessApproved(undefined)}`);
|
|
1118
|
+
}
|
|
1119
|
+
catch (err) {
|
|
1120
|
+
defaultLogger.warn('Failed to notify approved Bitrix24 user', err);
|
|
1121
|
+
}
|
|
1122
|
+
},
|
|
1123
|
+
},
|
|
1124
|
+
outbound: {
|
|
1125
|
+
deliveryMode: 'direct',
|
|
1126
|
+
textChunkLimit: 4000,
|
|
1127
|
+
sendText: async (ctx) => {
|
|
1128
|
+
const sendCtx = resolveOutboundSendCtx(ctx);
|
|
1129
|
+
if (!sendCtx || !gatewayState)
|
|
1130
|
+
throw new Error('Bitrix24 gateway not started');
|
|
1131
|
+
// Agent may embed button JSON in text as [[{...}]]
|
|
1132
|
+
let text = ctx.text;
|
|
1133
|
+
let keyboard;
|
|
1134
|
+
const extracted = extractInlineButtonsFromText(text);
|
|
1135
|
+
if (extracted) {
|
|
1136
|
+
text = extracted.cleanText;
|
|
1137
|
+
keyboard = extracted.keyboard;
|
|
1138
|
+
}
|
|
1139
|
+
const result = await gatewayState.sendService.sendText(sendCtx, text, keyboard ? { keyboard } : undefined);
|
|
1140
|
+
return { messageId: String(result.messageId ?? '') };
|
|
1141
|
+
},
|
|
1142
|
+
sendMedia: async (ctx) => {
|
|
1143
|
+
const sendCtx = resolveOutboundSendCtx(ctx);
|
|
1144
|
+
if (!sendCtx || !gatewayState)
|
|
1145
|
+
throw new Error('Bitrix24 gateway not started');
|
|
1146
|
+
const mediaUrls = collectOutboundMediaUrls({ mediaUrl: ctx.mediaUrl });
|
|
1147
|
+
if (mediaUrls.length > 0) {
|
|
1148
|
+
const messageId = await uploadOutboundMedia({
|
|
1149
|
+
mediaService: gatewayState.mediaService,
|
|
1150
|
+
sendCtx,
|
|
1151
|
+
mediaUrls,
|
|
1152
|
+
initialMessage: ctx.text,
|
|
1153
|
+
});
|
|
1154
|
+
return { messageId };
|
|
1155
|
+
}
|
|
1156
|
+
if (ctx.text) {
|
|
1157
|
+
const result = await gatewayState.sendService.sendText(sendCtx, ctx.text);
|
|
1158
|
+
return { messageId: String(result.messageId ?? '') };
|
|
1159
|
+
}
|
|
1160
|
+
return { messageId: '' };
|
|
1161
|
+
},
|
|
1162
|
+
sendPayload: async (ctx) => {
|
|
1163
|
+
const sendCtx = resolveOutboundSendCtx(ctx);
|
|
1164
|
+
if (!sendCtx || !gatewayState)
|
|
1165
|
+
throw new Error('Bitrix24 gateway not started');
|
|
1166
|
+
const keyboard = ctx.payload?.channelData
|
|
1167
|
+
? extractKeyboardFromPayload({ channelData: ctx.payload.channelData })
|
|
1168
|
+
: undefined;
|
|
1169
|
+
const mediaUrls = collectOutboundMediaUrls({
|
|
1170
|
+
mediaUrl: ctx.mediaUrl,
|
|
1171
|
+
payload: ctx.payload,
|
|
1172
|
+
});
|
|
1173
|
+
if (mediaUrls.length > 0) {
|
|
1174
|
+
const uploadedMessageId = await uploadOutboundMedia({
|
|
1175
|
+
mediaService: gatewayState.mediaService,
|
|
1176
|
+
sendCtx,
|
|
1177
|
+
mediaUrls,
|
|
1178
|
+
});
|
|
1179
|
+
if (ctx.text) {
|
|
1180
|
+
const result = await gatewayState.sendService.sendText(sendCtx, ctx.text, keyboard ? { keyboard } : undefined);
|
|
1181
|
+
return { messageId: String(result.messageId ?? uploadedMessageId) };
|
|
1182
|
+
}
|
|
1183
|
+
return { messageId: uploadedMessageId };
|
|
1184
|
+
}
|
|
1185
|
+
if (ctx.text) {
|
|
1186
|
+
const result = await gatewayState.sendService.sendText(sendCtx, ctx.text, keyboard ? { keyboard } : undefined);
|
|
1187
|
+
return { messageId: String(result.messageId ?? '') };
|
|
1188
|
+
}
|
|
1189
|
+
return { messageId: '' };
|
|
1190
|
+
},
|
|
1191
|
+
},
|
|
1192
|
+
// ─── Actions (agent-driven: reactions, etc.) ────────────────────────────
|
|
1193
|
+
actions: {
|
|
1194
|
+
listActions: (_params) => {
|
|
1195
|
+
return ['react', 'send'];
|
|
1196
|
+
},
|
|
1197
|
+
supportsAction: (params) => {
|
|
1198
|
+
return params.action === 'react' || params.action === 'send';
|
|
1199
|
+
},
|
|
1200
|
+
handleAction: async (ctx) => {
|
|
1201
|
+
// Helper: wrap payload as gateway-compatible tool result
|
|
1202
|
+
const toolResult = (payload) => ({
|
|
1203
|
+
content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }],
|
|
1204
|
+
details: payload,
|
|
1205
|
+
});
|
|
1206
|
+
// ─── Send with buttons ──────────────────────────────────────────────
|
|
1207
|
+
if (ctx.action === 'send') {
|
|
1208
|
+
// Only intercept send when buttons are present; otherwise let gateway handle normally
|
|
1209
|
+
const rawButtons = ctx.params.buttons;
|
|
1210
|
+
if (!rawButtons)
|
|
1211
|
+
return null;
|
|
1212
|
+
const { config } = resolveAccount(ctx.cfg, ctx.accountId);
|
|
1213
|
+
if (!config.webhookUrl || !gatewayState)
|
|
1214
|
+
return null;
|
|
1215
|
+
const bot = gatewayState.bot;
|
|
1216
|
+
const to = String(ctx.params.to ?? ctx.to ?? '').trim();
|
|
1217
|
+
if (!to) {
|
|
1218
|
+
defaultLogger.warn('handleAction send: no "to" in params or ctx, falling back to gateway');
|
|
1219
|
+
return null;
|
|
1220
|
+
}
|
|
1221
|
+
const sendCtx = { webhookUrl: config.webhookUrl, bot, dialogId: to };
|
|
1222
|
+
const message = String(ctx.params.message ?? '').trim();
|
|
1223
|
+
// Parse buttons: may be array or JSON string
|
|
1224
|
+
let buttons;
|
|
1225
|
+
try {
|
|
1226
|
+
const parsed = typeof rawButtons === 'string' ? JSON.parse(rawButtons) : rawButtons;
|
|
1227
|
+
if (Array.isArray(parsed))
|
|
1228
|
+
buttons = parsed;
|
|
1229
|
+
}
|
|
1230
|
+
catch {
|
|
1231
|
+
// invalid buttons JSON — send without keyboard
|
|
1232
|
+
}
|
|
1233
|
+
const keyboard = buttons?.length ? convertButtonsToKeyboard(buttons) : undefined;
|
|
1234
|
+
try {
|
|
1235
|
+
const result = await gatewayState.sendService.sendText(sendCtx, message || ' ', keyboard ? { keyboard } : undefined);
|
|
1236
|
+
return toolResult({
|
|
1237
|
+
channel: 'bitrix24',
|
|
1238
|
+
to,
|
|
1239
|
+
via: 'direct',
|
|
1240
|
+
mediaUrl: null,
|
|
1241
|
+
result: { messageId: String(result.messageId ?? '') },
|
|
1242
|
+
});
|
|
1243
|
+
}
|
|
1244
|
+
catch (err) {
|
|
1245
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
1246
|
+
return toolResult({ ok: false, error: errMsg });
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
// ─── React ────────────────────────────────────────────────────────────
|
|
1250
|
+
if (ctx.action !== 'react')
|
|
1251
|
+
return null;
|
|
1252
|
+
const { config } = resolveAccount(ctx.cfg, ctx.accountId);
|
|
1253
|
+
if (!config.webhookUrl || !gatewayState) {
|
|
1254
|
+
return toolResult({ ok: false, reason: 'not_started', hint: 'Bitrix24 gateway not started. Do not retry.' });
|
|
1255
|
+
}
|
|
1256
|
+
const bot = gatewayState.bot;
|
|
1257
|
+
const api = gatewayState.api;
|
|
1258
|
+
const params = ctx.params;
|
|
1259
|
+
// Resolve messageId: explicit param → toolContext.currentMessageId fallback
|
|
1260
|
+
const toolContext = ctx.toolContext;
|
|
1261
|
+
const rawMessageId = params.messageId ?? params.message_id ?? toolContext?.currentMessageId;
|
|
1262
|
+
const messageId = Number(rawMessageId);
|
|
1263
|
+
if (!Number.isFinite(messageId) || messageId <= 0) {
|
|
1264
|
+
return toolResult({ ok: false, reason: 'missing_message_id', hint: 'Valid messageId is required for Bitrix24 reactions. Do not retry.' });
|
|
1265
|
+
}
|
|
1266
|
+
const emoji = String(params.emoji ?? '').trim();
|
|
1267
|
+
const remove = params.remove === true || params.remove === 'true';
|
|
1268
|
+
if (remove) {
|
|
1269
|
+
// Remove reaction — need to know which one
|
|
1270
|
+
const reactionCode = emoji ? resolveB24Reaction(emoji) : null;
|
|
1271
|
+
if (!reactionCode) {
|
|
1272
|
+
return toolResult({ ok: false, reason: 'missing_emoji', hint: 'Emoji is required to remove a Bitrix24 reaction.' });
|
|
1273
|
+
}
|
|
1274
|
+
try {
|
|
1275
|
+
await api.deleteReaction(config.webhookUrl, bot, messageId, reactionCode);
|
|
1276
|
+
return toolResult({ ok: true, removed: true });
|
|
1277
|
+
}
|
|
1278
|
+
catch (err) {
|
|
1279
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
1280
|
+
return toolResult({ ok: false, reason: 'error', hint: `Failed to remove reaction: ${errMsg}. Do not retry.` });
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
// Add reaction
|
|
1284
|
+
if (!emoji) {
|
|
1285
|
+
return toolResult({ ok: false, reason: 'missing_emoji', hint: 'Emoji is required for Bitrix24 reactions.' });
|
|
1286
|
+
}
|
|
1287
|
+
const reactionCode = resolveB24Reaction(emoji);
|
|
1288
|
+
if (!reactionCode) {
|
|
1289
|
+
return toolResult({
|
|
1290
|
+
ok: false,
|
|
1291
|
+
reason: 'REACTION_NOT_FOUND',
|
|
1292
|
+
emoji,
|
|
1293
|
+
hint: `Emoji "${emoji}" is not supported for Bitrix24 reactions. Add it to your reaction disallow list so you do not try it again.`,
|
|
1294
|
+
});
|
|
1295
|
+
}
|
|
1296
|
+
try {
|
|
1297
|
+
await api.addReaction(config.webhookUrl, bot, messageId, reactionCode);
|
|
1298
|
+
return toolResult({ ok: true, added: emoji });
|
|
1299
|
+
}
|
|
1300
|
+
catch (err) {
|
|
1301
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
1302
|
+
const isAlreadySet = errMsg.includes('REACTION_ALREADY_SET');
|
|
1303
|
+
if (isAlreadySet) {
|
|
1304
|
+
return toolResult({ ok: true, added: emoji, warning: 'Reaction already set.' });
|
|
1305
|
+
}
|
|
1306
|
+
return toolResult({ ok: false, reason: 'error', emoji, hint: `Reaction failed: ${errMsg}. Do not retry.` });
|
|
1307
|
+
}
|
|
1308
|
+
},
|
|
1309
|
+
},
|
|
1310
|
+
gateway: {
|
|
1311
|
+
startAccount: async (ctx) => {
|
|
1312
|
+
// Guard: only one account can run at a time (singleton gateway)
|
|
1313
|
+
if (gatewayState !== null) {
|
|
1314
|
+
throw new Error(`Bitrix24 channel already started for account "${gatewayState.accountId}". ` +
|
|
1315
|
+
`Cannot start account "${ctx.accountId}" concurrently.`);
|
|
1316
|
+
}
|
|
1317
|
+
const config = getConfig(ctx.cfg, ctx.accountId);
|
|
1318
|
+
const logger = createVerboseLogger(ctx.log ?? defaultLogger, Boolean(config.verboseLog));
|
|
1319
|
+
if (!config.webhookUrl) {
|
|
1320
|
+
logger.warn(`[${ctx.accountId}] no webhookUrl configured, skipping`);
|
|
1321
|
+
return;
|
|
1322
|
+
}
|
|
1323
|
+
// Safe to use without ! after this point
|
|
1324
|
+
const webhookUrl = config.webhookUrl;
|
|
1325
|
+
logger.info(`[${ctx.accountId}] starting Bitrix24 channel`);
|
|
1326
|
+
const api = new Bitrix24Api({ logger });
|
|
1327
|
+
const botToken = resolveBotToken(config);
|
|
1328
|
+
if (!botToken) {
|
|
1329
|
+
logger.error(`[${ctx.accountId}] cannot derive botToken — webhookUrl is missing`);
|
|
1330
|
+
api.destroy();
|
|
1331
|
+
return;
|
|
1332
|
+
}
|
|
1333
|
+
const welcomedDialogs = new Set();
|
|
1334
|
+
const dialogNoticeTimestamps = new Map();
|
|
1335
|
+
const historyCache = new HistoryCache({ maxKeys: HISTORY_CACHE_MAX_KEYS });
|
|
1336
|
+
// Cleanup stale denied dialog entries once per day
|
|
1337
|
+
const DENIED_CLEANUP_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
|
1338
|
+
const deniedCleanupTimer = setInterval(() => {
|
|
1339
|
+
dialogNoticeTimestamps.clear();
|
|
1340
|
+
}, DENIED_CLEANUP_INTERVAL_MS);
|
|
1341
|
+
if (deniedCleanupTimer && typeof deniedCleanupTimer === 'object' && 'unref' in deniedCleanupTimer) {
|
|
1342
|
+
deniedCleanupTimer.unref();
|
|
1343
|
+
}
|
|
1344
|
+
// Determine event mode
|
|
1345
|
+
const eventMode = resolveEventMode(config);
|
|
1346
|
+
logger.info(`[${ctx.accountId}] event mode: ${eventMode}`);
|
|
1347
|
+
// Register or update bot on the B24 portal (V2 API)
|
|
1348
|
+
const botRegistration = await ensureBotRegistered(api, config, botToken, eventMode, logger);
|
|
1349
|
+
if (!botRegistration) {
|
|
1350
|
+
logger.error(`[${ctx.accountId}] bot registration failed, cannot start`);
|
|
1351
|
+
clearInterval(deniedCleanupTimer);
|
|
1352
|
+
api.destroy();
|
|
1353
|
+
return;
|
|
1354
|
+
}
|
|
1355
|
+
const bot = { botId: botRegistration.botId, botToken };
|
|
1356
|
+
const webhookOwnerId = getWebhookUserId(config.webhookUrl);
|
|
1357
|
+
// Sync user event subscription with agent mode setting
|
|
1358
|
+
if (eventMode === 'fetch') {
|
|
1359
|
+
try {
|
|
1360
|
+
if (config.agentMode) {
|
|
1361
|
+
await api.subscribeUserEvents(webhookUrl);
|
|
1362
|
+
logger.info('User events subscription active (agent mode)');
|
|
1363
|
+
}
|
|
1364
|
+
else {
|
|
1365
|
+
await api.unsubscribeUserEvents(webhookUrl);
|
|
1366
|
+
logger.debug('User events unsubscribed (agent mode off)');
|
|
1367
|
+
}
|
|
1368
|
+
}
|
|
1369
|
+
catch (err) {
|
|
1370
|
+
logger.warn('Failed to sync user events subscription', err);
|
|
1371
|
+
}
|
|
1372
|
+
}
|
|
1373
|
+
const sendService = new SendService(api, logger);
|
|
1374
|
+
const mediaService = new MediaService(api, logger);
|
|
1375
|
+
const hydrateReplyEntry = async (replyToMessageId) => {
|
|
1376
|
+
const bitrixMessageId = toMessageId(replyToMessageId);
|
|
1377
|
+
if (!bitrixMessageId) {
|
|
1378
|
+
return undefined;
|
|
1379
|
+
}
|
|
1380
|
+
try {
|
|
1381
|
+
const result = await api.getMessage(webhookUrl, bot, bitrixMessageId);
|
|
1382
|
+
return {
|
|
1383
|
+
sender: result.user?.name?.trim() || `User ${result.message.authorId}`,
|
|
1384
|
+
body: resolveFetchedMessageBody(result.message.text),
|
|
1385
|
+
messageId: String(result.message.id),
|
|
1386
|
+
};
|
|
1387
|
+
}
|
|
1388
|
+
catch (err) {
|
|
1389
|
+
logger.debug('Failed to hydrate reply context from Bitrix24 API', {
|
|
1390
|
+
replyToMessageId,
|
|
1391
|
+
error: err,
|
|
1392
|
+
});
|
|
1393
|
+
return undefined;
|
|
1394
|
+
}
|
|
1395
|
+
};
|
|
1396
|
+
const hydrateForwardedMessageContext = async (msgCtx) => {
|
|
1397
|
+
const currentMessageId = toMessageId(msgCtx.messageId);
|
|
1398
|
+
if (!currentMessageId) {
|
|
1399
|
+
return {
|
|
1400
|
+
...msgCtx,
|
|
1401
|
+
isForwarded: false,
|
|
1402
|
+
};
|
|
1403
|
+
}
|
|
1404
|
+
try {
|
|
1405
|
+
const result = await api.getMessageContext(webhookUrl, bot, currentMessageId, FORWARDED_CONTEXT_RANGE);
|
|
1406
|
+
const currentMessage = result.messages.find((message) => message.id === currentMessageId);
|
|
1407
|
+
const currentBody = resolveFetchedMessageBody(currentMessage?.text ?? msgCtx.text);
|
|
1408
|
+
const fetchedContext = formatFetchedForwardContext({
|
|
1409
|
+
context: result,
|
|
1410
|
+
currentMessageId,
|
|
1411
|
+
});
|
|
1412
|
+
if (!currentBody && !fetchedContext) {
|
|
1413
|
+
return {
|
|
1414
|
+
...msgCtx,
|
|
1415
|
+
isForwarded: false,
|
|
1416
|
+
};
|
|
1417
|
+
}
|
|
1418
|
+
return {
|
|
1419
|
+
...msgCtx,
|
|
1420
|
+
text: [
|
|
1421
|
+
currentBody ? '[Forwarded message body]' : '',
|
|
1422
|
+
currentBody,
|
|
1423
|
+
currentBody ? '[/Forwarded message body]' : '',
|
|
1424
|
+
fetchedContext,
|
|
1425
|
+
].filter(Boolean).join('\n\n'),
|
|
1426
|
+
isForwarded: false,
|
|
1427
|
+
};
|
|
1428
|
+
}
|
|
1429
|
+
catch (err) {
|
|
1430
|
+
logger.debug('Failed to hydrate forwarded message context from Bitrix24 API', {
|
|
1431
|
+
messageId: msgCtx.messageId,
|
|
1432
|
+
chatId: msgCtx.chatId,
|
|
1433
|
+
error: err,
|
|
1434
|
+
});
|
|
1435
|
+
return {
|
|
1436
|
+
...msgCtx,
|
|
1437
|
+
isForwarded: false,
|
|
1438
|
+
};
|
|
1439
|
+
}
|
|
1440
|
+
};
|
|
1441
|
+
const processAllowedMessage = async (msgCtx) => {
|
|
1442
|
+
const runtime = getBitrix24Runtime();
|
|
1443
|
+
const cfg = runtime.config.loadConfig();
|
|
1444
|
+
const conversation = resolveConversationRef({
|
|
1445
|
+
accountId: ctx.accountId,
|
|
1446
|
+
dialogId: msgCtx.chatId,
|
|
1447
|
+
isDirect: msgCtx.isDm,
|
|
1448
|
+
});
|
|
1449
|
+
const sendCtx = {
|
|
1450
|
+
webhookUrl,
|
|
1451
|
+
bot,
|
|
1452
|
+
dialogId: conversation.dialogId,
|
|
1453
|
+
};
|
|
1454
|
+
const historyKey = conversation.historyKey;
|
|
1455
|
+
const historyLimit = resolveHistoryLimit(config);
|
|
1456
|
+
const fallbackHistoryBody = buildHistoryBody(msgCtx);
|
|
1457
|
+
let downloadedMedia = [];
|
|
1458
|
+
let historyRecorded = false;
|
|
1459
|
+
const recordHistory = (bodyOverride) => {
|
|
1460
|
+
if (historyRecorded) {
|
|
1461
|
+
return;
|
|
1462
|
+
}
|
|
1463
|
+
const historyBody = (bodyOverride ?? fallbackHistoryBody).trim();
|
|
1464
|
+
if (!historyBody) {
|
|
1465
|
+
return;
|
|
1466
|
+
}
|
|
1467
|
+
appendMessageToHistory({
|
|
1468
|
+
historyCache,
|
|
1469
|
+
historyKey,
|
|
1470
|
+
historyLimit,
|
|
1471
|
+
msgCtx,
|
|
1472
|
+
body: historyBody,
|
|
1473
|
+
});
|
|
1474
|
+
historyRecorded = true;
|
|
1475
|
+
};
|
|
1476
|
+
try {
|
|
1477
|
+
const replyStatusHeartbeat = createReplyStatusHeartbeat({
|
|
1478
|
+
sendService,
|
|
1479
|
+
sendCtx,
|
|
1480
|
+
config,
|
|
1481
|
+
});
|
|
1482
|
+
// Download media files if present
|
|
1483
|
+
let mediaFields = {};
|
|
1484
|
+
if (msgCtx.media.length > 0) {
|
|
1485
|
+
await notifyStatus(sendService, sendCtx, config, 'IMBOT_AGENT_ACTION_PROCESSING');
|
|
1486
|
+
replyStatusHeartbeat.holdFor(PHASE_STATUS_DURATION_SECONDS);
|
|
1487
|
+
downloadedMedia = (await mapWithConcurrency(msgCtx.media, MEDIA_DOWNLOAD_CONCURRENCY, (mediaItem) => mediaService.downloadMedia({
|
|
1488
|
+
fileId: mediaItem.id,
|
|
1489
|
+
fileName: mediaItem.name,
|
|
1490
|
+
extension: mediaItem.extension,
|
|
1491
|
+
webhookUrl,
|
|
1492
|
+
bot,
|
|
1493
|
+
dialogId: conversation.dialogId,
|
|
1494
|
+
}))).filter(Boolean);
|
|
1495
|
+
if (downloadedMedia.length > 0) {
|
|
1496
|
+
mediaFields = {
|
|
1497
|
+
MediaPath: downloadedMedia[0].path,
|
|
1498
|
+
MediaType: downloadedMedia[0].contentType,
|
|
1499
|
+
MediaUrl: downloadedMedia[0].path,
|
|
1500
|
+
MediaPaths: downloadedMedia.map((mediaItem) => mediaItem.path),
|
|
1501
|
+
MediaUrls: downloadedMedia.map((mediaItem) => mediaItem.path),
|
|
1502
|
+
MediaTypes: downloadedMedia.map((mediaItem) => mediaItem.contentType),
|
|
1503
|
+
};
|
|
1504
|
+
}
|
|
1505
|
+
else {
|
|
1506
|
+
const fileNames = msgCtx.media.map((mediaItem) => mediaItem.name).join(', ');
|
|
1507
|
+
logger.warn('All media downloads failed, notifying user', { fileNames });
|
|
1508
|
+
recordHistory();
|
|
1509
|
+
await sendService.sendText(sendCtx, mediaDownloadFailed(msgCtx.language, fileNames));
|
|
1510
|
+
return;
|
|
1511
|
+
}
|
|
1512
|
+
}
|
|
1513
|
+
else {
|
|
1514
|
+
await notifyStatus(sendService, sendCtx, config, 'IMBOT_AGENT_ACTION_ANALYZING');
|
|
1515
|
+
replyStatusHeartbeat.holdFor(PHASE_STATUS_DURATION_SECONDS);
|
|
1516
|
+
}
|
|
1517
|
+
// Use placeholder body for media-only messages
|
|
1518
|
+
let body = msgCtx.text;
|
|
1519
|
+
if (!body && msgCtx.media.length > 0) {
|
|
1520
|
+
const hasImage = downloadedMedia.some((mediaItem) => mediaItem.contentType.startsWith('image/'))
|
|
1521
|
+
|| msgCtx.media.some((mediaItem) => mediaItem.type === 'image');
|
|
1522
|
+
body = hasImage ? '<media:image>' : '<media:document>';
|
|
1523
|
+
}
|
|
1524
|
+
const previousEntries = historyCache.get(historyKey, historyLimit);
|
|
1525
|
+
const replyEntry = historyCache.findByMessageId(historyKey, msgCtx.replyToMessageId)
|
|
1526
|
+
?? await hydrateReplyEntry(msgCtx.replyToMessageId);
|
|
1527
|
+
const bodyWithReply = formatReplyContext({
|
|
1528
|
+
body,
|
|
1529
|
+
replyEntry,
|
|
1530
|
+
});
|
|
1531
|
+
const crossChatContext = buildCrossChatMemoryContext({
|
|
1532
|
+
query: bodyWithReply,
|
|
1533
|
+
historyCache,
|
|
1534
|
+
});
|
|
1535
|
+
const bodyForAgent = crossChatContext
|
|
1536
|
+
? [crossChatContext, bodyWithReply].filter(Boolean).join('\n\n')
|
|
1537
|
+
: bodyWithReply;
|
|
1538
|
+
const combinedBody = msgCtx.isGroup
|
|
1539
|
+
? buildHistoryContext({
|
|
1540
|
+
entries: previousEntries,
|
|
1541
|
+
currentBody: bodyForAgent,
|
|
1542
|
+
})
|
|
1543
|
+
: bodyForAgent;
|
|
1544
|
+
recordHistory(body);
|
|
1545
|
+
// Resolve which agent handles this conversation
|
|
1546
|
+
const route = runtime.channel.routing.resolveAgentRoute({
|
|
1547
|
+
cfg,
|
|
1548
|
+
channel: 'bitrix24',
|
|
1549
|
+
accountId: ctx.accountId,
|
|
1550
|
+
peer: conversation.peer,
|
|
1551
|
+
});
|
|
1552
|
+
logger.debug('Resolved route', {
|
|
1553
|
+
sessionKey: route.sessionKey,
|
|
1554
|
+
agentId: route.agentId,
|
|
1555
|
+
matchedBy: route.matchedBy,
|
|
1556
|
+
});
|
|
1557
|
+
const inboundCtx = runtime.channel.reply.finalizeInboundContext({
|
|
1558
|
+
Body: combinedBody,
|
|
1559
|
+
BodyForAgent: bodyForAgent,
|
|
1560
|
+
InboundHistory: msgCtx.isGroup && previousEntries.length > 0
|
|
1561
|
+
? previousEntries.map((entry) => ({
|
|
1562
|
+
sender: entry.sender,
|
|
1563
|
+
body: entry.body,
|
|
1564
|
+
timestamp: entry.timestamp,
|
|
1565
|
+
}))
|
|
1566
|
+
: undefined,
|
|
1567
|
+
RawBody: body,
|
|
1568
|
+
From: conversation.address,
|
|
1569
|
+
To: conversation.address,
|
|
1570
|
+
SessionKey: buildConversationSessionKey(route.sessionKey, conversation),
|
|
1571
|
+
AccountId: route.accountId,
|
|
1572
|
+
ChatType: msgCtx.isDm ? 'direct' : 'group',
|
|
1573
|
+
ConversationLabel: msgCtx.senderName,
|
|
1574
|
+
SenderName: msgCtx.senderName,
|
|
1575
|
+
SenderId: msgCtx.senderId,
|
|
1576
|
+
Provider: 'bitrix24',
|
|
1577
|
+
Surface: 'bitrix24',
|
|
1578
|
+
MessageSid: msgCtx.messageId,
|
|
1579
|
+
Timestamp: msgCtx.timestamp ?? Date.now(),
|
|
1580
|
+
ReplyToId: replyEntry?.messageId ?? msgCtx.replyToMessageId,
|
|
1581
|
+
ReplyToBody: replyEntry?.body,
|
|
1582
|
+
ReplyToSender: replyEntry?.sender,
|
|
1583
|
+
WasMentioned: msgCtx.wasMentioned ?? false,
|
|
1584
|
+
CommandAuthorized: true,
|
|
1585
|
+
OriginatingChannel: 'bitrix24',
|
|
1586
|
+
OriginatingTo: conversation.address,
|
|
1587
|
+
...mediaFields,
|
|
1588
|
+
});
|
|
1589
|
+
try {
|
|
1590
|
+
await runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
1591
|
+
ctx: inboundCtx,
|
|
1592
|
+
cfg,
|
|
1593
|
+
dispatcherOptions: {
|
|
1594
|
+
deliver: async (payload) => {
|
|
1595
|
+
await replyStatusHeartbeat.stopAndWait();
|
|
1596
|
+
const mediaUrls = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
|
|
1597
|
+
if (mediaUrls.length > 0) {
|
|
1598
|
+
await uploadOutboundMedia({
|
|
1599
|
+
mediaService,
|
|
1600
|
+
sendCtx,
|
|
1601
|
+
mediaUrls,
|
|
1602
|
+
});
|
|
1603
|
+
}
|
|
1604
|
+
if (payload.text) {
|
|
1605
|
+
let text = payload.text;
|
|
1606
|
+
let keyboard = extractKeyboardFromPayload(payload);
|
|
1607
|
+
// Fallback: agent may embed button JSON in text as [[{...}]]
|
|
1608
|
+
if (!keyboard) {
|
|
1609
|
+
const extracted = extractInlineButtonsFromText(text);
|
|
1610
|
+
if (extracted) {
|
|
1611
|
+
text = extracted.cleanText;
|
|
1612
|
+
keyboard = extracted.keyboard;
|
|
1613
|
+
}
|
|
1614
|
+
}
|
|
1615
|
+
await sendService.sendText(sendCtx, text, keyboard ? { keyboard } : undefined);
|
|
1616
|
+
}
|
|
1617
|
+
},
|
|
1618
|
+
onReplyStart: async () => {
|
|
1619
|
+
await replyStatusHeartbeat.start();
|
|
1620
|
+
},
|
|
1621
|
+
onError: (err) => {
|
|
1622
|
+
logger.error('Error delivering reply to B24', err);
|
|
1623
|
+
},
|
|
1624
|
+
},
|
|
1625
|
+
});
|
|
1626
|
+
}
|
|
1627
|
+
catch (err) {
|
|
1628
|
+
logger.error('Error dispatching message to agent', { senderId: msgCtx.senderId, chatId: msgCtx.chatId, error: err });
|
|
1629
|
+
}
|
|
1630
|
+
finally {
|
|
1631
|
+
replyStatusHeartbeat.stop();
|
|
1632
|
+
}
|
|
1633
|
+
}
|
|
1634
|
+
finally {
|
|
1635
|
+
await mediaService.cleanupDownloadedMedia(downloadedMedia.map((mediaItem) => mediaItem.path));
|
|
1636
|
+
}
|
|
1637
|
+
};
|
|
1638
|
+
const directTextCoalescer = new BufferedDirectMessageCoalescer({
|
|
1639
|
+
debounceMs: DIRECT_TEXT_COALESCE_DEBOUNCE_MS,
|
|
1640
|
+
maxWaitMs: DIRECT_TEXT_COALESCE_MAX_WAIT_MS,
|
|
1641
|
+
onFlush: processAllowedMessage,
|
|
1642
|
+
logger,
|
|
1643
|
+
});
|
|
1644
|
+
const maybeSendDialogNotice = async (noticeKey, sendCtx, text) => {
|
|
1645
|
+
const now = Date.now();
|
|
1646
|
+
const lastSentAt = dialogNoticeTimestamps.get(noticeKey) ?? 0;
|
|
1647
|
+
if ((now - lastSentAt) < ACCESS_DENIED_NOTICE_COOLDOWN_MS) {
|
|
1648
|
+
return;
|
|
1649
|
+
}
|
|
1650
|
+
dialogNoticeTimestamps.set(noticeKey, now);
|
|
1651
|
+
try {
|
|
1652
|
+
await sendService.sendText(sendCtx, text, { convertMarkdown: false });
|
|
1653
|
+
}
|
|
1654
|
+
catch (err) {
|
|
1655
|
+
logger.warn('Failed to send dialog notice', err);
|
|
1656
|
+
}
|
|
1657
|
+
};
|
|
1658
|
+
const maybeReactToDeniedMention = async (msgCtx) => {
|
|
1659
|
+
if (!msgCtx.isGroup || !msgCtx.wasMentioned) {
|
|
1660
|
+
return;
|
|
1661
|
+
}
|
|
1662
|
+
const messageId = toMessageId(msgCtx.messageId);
|
|
1663
|
+
if (!messageId) {
|
|
1664
|
+
return;
|
|
1665
|
+
}
|
|
1666
|
+
try {
|
|
1667
|
+
await api.addReaction(webhookUrl, bot, messageId, ACCESS_DENIED_REACTION);
|
|
1668
|
+
}
|
|
1669
|
+
catch (err) {
|
|
1670
|
+
logger.debug('Failed to add access-denied reaction', {
|
|
1671
|
+
chatId: msgCtx.chatId,
|
|
1672
|
+
messageId: msgCtx.messageId,
|
|
1673
|
+
error: err,
|
|
1674
|
+
});
|
|
1675
|
+
}
|
|
1676
|
+
};
|
|
1677
|
+
const maybeReactToBotMessageReaction = async (reactionCtx) => {
|
|
1678
|
+
if (reactionCtx.action !== 'set') {
|
|
1679
|
+
return;
|
|
1680
|
+
}
|
|
1681
|
+
const botId = String(bot.botId);
|
|
1682
|
+
if (reactionCtx.senderId === botId || reactionCtx.messageAuthorId !== botId) {
|
|
1683
|
+
return;
|
|
1684
|
+
}
|
|
1685
|
+
const messageId = toMessageId(reactionCtx.messageId);
|
|
1686
|
+
if (!messageId) {
|
|
1687
|
+
return;
|
|
1688
|
+
}
|
|
1689
|
+
try {
|
|
1690
|
+
await api.addReaction(webhookUrl, bot, messageId, BOT_MESSAGE_WATCH_REACTION);
|
|
1691
|
+
}
|
|
1692
|
+
catch (err) {
|
|
1693
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
1694
|
+
if (errMsg.includes('REACTION_ALREADY_SET')) {
|
|
1695
|
+
logger.debug('Watch reaction already set on bot message', {
|
|
1696
|
+
chatId: reactionCtx.dialogId,
|
|
1697
|
+
messageId: reactionCtx.messageId,
|
|
1698
|
+
});
|
|
1699
|
+
return;
|
|
1700
|
+
}
|
|
1701
|
+
logger.warn('Failed to add watch reaction to bot message', {
|
|
1702
|
+
chatId: reactionCtx.dialogId,
|
|
1703
|
+
messageId: reactionCtx.messageId,
|
|
1704
|
+
error: err,
|
|
1705
|
+
});
|
|
1706
|
+
}
|
|
1707
|
+
};
|
|
1708
|
+
const notifyWebhookOwnerAboutWatchMatch = async (msgCtx, watchRule) => {
|
|
1709
|
+
const ownerId = webhookOwnerId;
|
|
1710
|
+
const forwardedMessageId = toMessageId(msgCtx.messageId);
|
|
1711
|
+
if (!ownerId || !forwardedMessageId) {
|
|
1712
|
+
logger.warn('Skipping owner watch notification: missing owner dialog or message id', {
|
|
1713
|
+
ownerId,
|
|
1714
|
+
messageId: msgCtx.messageId,
|
|
1715
|
+
chatId: msgCtx.chatId,
|
|
1716
|
+
});
|
|
1717
|
+
return false;
|
|
1718
|
+
}
|
|
1719
|
+
const ownerSendCtx = {
|
|
1720
|
+
webhookUrl,
|
|
1721
|
+
bot,
|
|
1722
|
+
dialogId: ownerId,
|
|
1723
|
+
};
|
|
1724
|
+
const noticeText = watchOwnerDmNotice(msgCtx.language, {
|
|
1725
|
+
chatRef: buildChatContextUrl(msgCtx.chatId, msgCtx.messageId, msgCtx.isDm
|
|
1726
|
+
? (msgCtx.senderName || msgCtx.chatName || msgCtx.chatId)
|
|
1727
|
+
: (msgCtx.chatName ?? msgCtx.chatId)),
|
|
1728
|
+
topicsRef: buildTopicsBbCode(watchRule.topics),
|
|
1729
|
+
sourceKind: msgCtx.isDm ? 'dm' : 'chat',
|
|
1730
|
+
});
|
|
1731
|
+
try {
|
|
1732
|
+
await sendService.sendText(ownerSendCtx, noticeText, {
|
|
1733
|
+
convertMarkdown: false,
|
|
1734
|
+
});
|
|
1735
|
+
if (msgCtx.eventScope === 'user' && msgCtx.isDm) {
|
|
1736
|
+
const quoteText = buildWatchQuoteText({
|
|
1737
|
+
senderName: msgCtx.senderName || msgCtx.chatName || msgCtx.chatId,
|
|
1738
|
+
language: msgCtx.language,
|
|
1739
|
+
timestamp: msgCtx.timestamp,
|
|
1740
|
+
anchor: `#${msgCtx.chatId}:${ownerId}/${msgCtx.messageId}`,
|
|
1741
|
+
body: msgCtx.text.trim(),
|
|
1742
|
+
});
|
|
1743
|
+
await sendService.sendText(ownerSendCtx, quoteText, {
|
|
1744
|
+
convertMarkdown: false,
|
|
1745
|
+
});
|
|
1746
|
+
return true;
|
|
1747
|
+
}
|
|
1748
|
+
await api.sendMessage(webhookUrl, bot, ownerId, null, { forwardMessages: [forwardedMessageId] });
|
|
1749
|
+
return true;
|
|
1750
|
+
}
|
|
1751
|
+
catch (err) {
|
|
1752
|
+
logger.warn('Failed to send owner watch notification with native forward', err);
|
|
1753
|
+
return false;
|
|
1754
|
+
}
|
|
1755
|
+
};
|
|
1756
|
+
// Register slash commands (runs in background)
|
|
1757
|
+
ensureCommandsRegistered(api, config, bot, logger).catch((err) => {
|
|
1758
|
+
logger.warn('Command registration failed', err);
|
|
1759
|
+
});
|
|
1760
|
+
if (botRegistration.isNew) {
|
|
1761
|
+
await sendInitialWelcomeToWebhookOwner({
|
|
1762
|
+
config,
|
|
1763
|
+
bot,
|
|
1764
|
+
sendService,
|
|
1765
|
+
language: botRegistration.language,
|
|
1766
|
+
welcomedDialogs,
|
|
1767
|
+
logger,
|
|
1768
|
+
});
|
|
1769
|
+
}
|
|
1770
|
+
const inboundHandler = new InboundHandler({
|
|
1771
|
+
config,
|
|
1772
|
+
logger,
|
|
1773
|
+
onReactionChange: async (reactionCtx) => {
|
|
1774
|
+
await maybeReactToBotMessageReaction(reactionCtx);
|
|
1775
|
+
},
|
|
1776
|
+
onMessage: async (msgCtx) => {
|
|
1777
|
+
logger.info('Inbound message', {
|
|
1778
|
+
senderId: msgCtx.senderId,
|
|
1779
|
+
chatId: msgCtx.chatId,
|
|
1780
|
+
messageId: msgCtx.messageId,
|
|
1781
|
+
textLen: msgCtx.text.length,
|
|
1782
|
+
});
|
|
1783
|
+
const pendingForwardContext = msgCtx.isForwarded
|
|
1784
|
+
? directTextCoalescer.take(ctx.accountId, msgCtx.chatId)
|
|
1785
|
+
: null;
|
|
1786
|
+
const runtime = getBitrix24Runtime();
|
|
1787
|
+
// Pairing-aware access control
|
|
1788
|
+
const sendCtx = {
|
|
1789
|
+
webhookUrl,
|
|
1790
|
+
bot,
|
|
1791
|
+
dialogId: msgCtx.chatId,
|
|
1792
|
+
};
|
|
1793
|
+
const conversation = resolveConversationRef({
|
|
1794
|
+
accountId: ctx.accountId,
|
|
1795
|
+
dialogId: msgCtx.chatId,
|
|
1796
|
+
isDirect: msgCtx.isDm,
|
|
1797
|
+
});
|
|
1798
|
+
const historyKey = conversation.historyKey;
|
|
1799
|
+
const historyLimit = resolveHistoryLimit(config);
|
|
1800
|
+
const groupAccess = msgCtx.isGroup
|
|
1801
|
+
? resolveGroupAccess({
|
|
1802
|
+
config,
|
|
1803
|
+
dialogId: msgCtx.chatId,
|
|
1804
|
+
chatId: msgCtx.chatInternalId,
|
|
1805
|
+
})
|
|
1806
|
+
: null;
|
|
1807
|
+
const agentWatchRules = config.agentMode && msgCtx.eventScope === 'user'
|
|
1808
|
+
? resolveAgentWatchRules({
|
|
1809
|
+
config,
|
|
1810
|
+
dialogId: msgCtx.chatId,
|
|
1811
|
+
chatId: msgCtx.chatInternalId,
|
|
1812
|
+
})
|
|
1813
|
+
: [];
|
|
1814
|
+
const watchRule = msgCtx.isGroup && groupAccess?.groupAllowed
|
|
1815
|
+
? findMatchingWatchRule(msgCtx, groupAccess?.watch)
|
|
1816
|
+
: undefined;
|
|
1817
|
+
const activeWatchRule = watchRule?.mode === 'notifyOwnerDm'
|
|
1818
|
+
&& webhookOwnerId
|
|
1819
|
+
&& msgCtx.senderId === webhookOwnerId
|
|
1820
|
+
? undefined
|
|
1821
|
+
: watchRule;
|
|
1822
|
+
const agentWatchRule = msgCtx.eventScope === 'user'
|
|
1823
|
+
? findMatchingWatchRule(msgCtx, agentWatchRules)
|
|
1824
|
+
: undefined;
|
|
1825
|
+
/** Shorthand: record message in RAM history for this dialog. */
|
|
1826
|
+
const recordHistory = (body) => appendMessageToHistory({ historyCache, historyKey, historyLimit, msgCtx, body });
|
|
1827
|
+
if (msgCtx.eventScope === 'user') {
|
|
1828
|
+
const isBotDialogUserEvent = msgCtx.isDm && msgCtx.chatId === String(bot.botId);
|
|
1829
|
+
const isBotAuthoredUserEvent = msgCtx.senderId === String(bot.botId);
|
|
1830
|
+
if (isBotDialogUserEvent || isBotAuthoredUserEvent) {
|
|
1831
|
+
logger.debug('Skipping agent-mode user event for bot-owned conversation', {
|
|
1832
|
+
senderId: msgCtx.senderId,
|
|
1833
|
+
chatId: msgCtx.chatId,
|
|
1834
|
+
messageId: msgCtx.messageId,
|
|
1835
|
+
isBotDialogUserEvent,
|
|
1836
|
+
isBotAuthoredUserEvent,
|
|
1837
|
+
});
|
|
1838
|
+
return;
|
|
1839
|
+
}
|
|
1840
|
+
recordHistory();
|
|
1841
|
+
if (webhookOwnerId && msgCtx.senderId !== webhookOwnerId && agentWatchRule?.mode === 'notifyOwnerDm') {
|
|
1842
|
+
await notifyWebhookOwnerAboutWatchMatch(msgCtx, agentWatchRule);
|
|
1843
|
+
logger.debug('User-event watch matched and notified webhook owner in DM', {
|
|
1844
|
+
senderId: msgCtx.senderId,
|
|
1845
|
+
chatId: msgCtx.chatId,
|
|
1846
|
+
messageId: msgCtx.messageId,
|
|
1847
|
+
});
|
|
1848
|
+
}
|
|
1849
|
+
return;
|
|
1850
|
+
}
|
|
1851
|
+
if (msgCtx.isGroup && groupAccess?.groupPolicy === 'disabled') {
|
|
1852
|
+
logger.info('Group chat is disabled by policy, leaving chat', {
|
|
1853
|
+
chatId: msgCtx.chatId,
|
|
1854
|
+
senderId: msgCtx.senderId,
|
|
1855
|
+
});
|
|
1856
|
+
try {
|
|
1857
|
+
await sendService.sendText(sendCtx, groupChatUnsupported(msgCtx.language));
|
|
1858
|
+
await api.leaveChat(webhookUrl, bot, msgCtx.chatId);
|
|
1859
|
+
}
|
|
1860
|
+
catch (err) {
|
|
1861
|
+
logger.error('Failed to leave disabled group chat', err);
|
|
1862
|
+
}
|
|
1863
|
+
return;
|
|
1864
|
+
}
|
|
1865
|
+
await sendService.markRead(sendCtx, toMessageId(msgCtx.messageId));
|
|
1866
|
+
if (msgCtx.isGroup
|
|
1867
|
+
&& !activeWatchRule
|
|
1868
|
+
&& groupAccess?.requireMention
|
|
1869
|
+
&& !msgCtx.wasMentioned) {
|
|
1870
|
+
recordHistory();
|
|
1871
|
+
logger.info('Skipping group message without mention', {
|
|
1872
|
+
chatId: msgCtx.chatId,
|
|
1873
|
+
senderId: msgCtx.senderId,
|
|
1874
|
+
messageId: msgCtx.messageId,
|
|
1875
|
+
});
|
|
1876
|
+
return;
|
|
1877
|
+
}
|
|
1878
|
+
if (activeWatchRule?.mode === 'notifyOwnerDm') {
|
|
1879
|
+
recordHistory();
|
|
1880
|
+
await notifyWebhookOwnerAboutWatchMatch(msgCtx, activeWatchRule);
|
|
1881
|
+
logger.debug('Group watch matched and notified webhook owner in DM', {
|
|
1882
|
+
senderId: msgCtx.senderId,
|
|
1883
|
+
chatId: msgCtx.chatId,
|
|
1884
|
+
messageId: msgCtx.messageId,
|
|
1885
|
+
});
|
|
1886
|
+
return;
|
|
1887
|
+
}
|
|
1888
|
+
const accessResult = activeWatchRule
|
|
1889
|
+
? 'allow'
|
|
1890
|
+
: msgCtx.isGroup
|
|
1891
|
+
? await checkGroupAccessWithPairing({
|
|
1892
|
+
senderId: msgCtx.senderId,
|
|
1893
|
+
dialogId: msgCtx.chatId,
|
|
1894
|
+
chatId: msgCtx.chatInternalId,
|
|
1895
|
+
config,
|
|
1896
|
+
runtime,
|
|
1897
|
+
accountId: ctx.accountId,
|
|
1898
|
+
pairingAdapter: bitrix24Plugin.pairing,
|
|
1899
|
+
logger,
|
|
1900
|
+
})
|
|
1901
|
+
: await checkAccessWithPairing({
|
|
1902
|
+
senderId: msgCtx.senderId,
|
|
1903
|
+
dialogId: msgCtx.chatId,
|
|
1904
|
+
isDirect: msgCtx.isDm,
|
|
1905
|
+
config,
|
|
1906
|
+
runtime,
|
|
1907
|
+
accountId: ctx.accountId,
|
|
1908
|
+
pairingAdapter: bitrix24Plugin.pairing,
|
|
1909
|
+
sendReply: async (text) => {
|
|
1910
|
+
await sendService.sendText(sendCtx, text, { convertMarkdown: false });
|
|
1911
|
+
},
|
|
1912
|
+
logger,
|
|
1913
|
+
});
|
|
1914
|
+
if (accessResult === 'deny') {
|
|
1915
|
+
if (msgCtx.isGroup) {
|
|
1916
|
+
recordHistory();
|
|
1917
|
+
if (!msgCtx.wasMentioned) {
|
|
1918
|
+
logger.debug('Group message blocked silently without mention', {
|
|
1919
|
+
senderId: msgCtx.senderId,
|
|
1920
|
+
chatId: msgCtx.chatId,
|
|
1921
|
+
messageId: msgCtx.messageId,
|
|
1922
|
+
});
|
|
1923
|
+
return;
|
|
1924
|
+
}
|
|
1925
|
+
}
|
|
1926
|
+
await maybeReactToDeniedMention(msgCtx);
|
|
1927
|
+
const noticeKey = msgCtx.isDm
|
|
1928
|
+
? `deny:${msgCtx.chatId}`
|
|
1929
|
+
: `group-deny:${msgCtx.chatId}:${msgCtx.senderId}`;
|
|
1930
|
+
const policy = msgCtx.isDm
|
|
1931
|
+
? config.dmPolicy
|
|
1932
|
+
: groupAccess?.groupPolicy;
|
|
1933
|
+
await maybeSendDialogNotice(noticeKey, sendCtx, buildAccessDeniedNotice(msgCtx.language, policy, {
|
|
1934
|
+
hasAllowList: msgCtx.isDm
|
|
1935
|
+
? normalizeAllowList(config.allowFrom).length > 0
|
|
1936
|
+
: Boolean(groupAccess?.senderAllowFrom.length),
|
|
1937
|
+
}));
|
|
1938
|
+
logger.debug('Message blocked (deny)', { senderId: msgCtx.senderId, chatId: msgCtx.chatId });
|
|
1939
|
+
return;
|
|
1940
|
+
}
|
|
1941
|
+
if (accessResult === 'pairing') {
|
|
1942
|
+
if (msgCtx.isGroup) {
|
|
1943
|
+
recordHistory();
|
|
1944
|
+
await maybeSendDialogNotice(`group-pairing:${msgCtx.chatId}:${msgCtx.senderId}`, sendCtx, groupPairingPending(msgCtx.language));
|
|
1945
|
+
}
|
|
1946
|
+
logger.debug(`Message blocked (${accessResult})`, { senderId: msgCtx.senderId });
|
|
1947
|
+
return;
|
|
1948
|
+
}
|
|
1949
|
+
await sendService.sendTyping(sendCtx);
|
|
1950
|
+
if (msgCtx.isForwarded) {
|
|
1951
|
+
if (pendingForwardContext) {
|
|
1952
|
+
const hydratedForwardContext = await hydrateForwardedMessageContext(msgCtx);
|
|
1953
|
+
const mergedForwardContext = mergeForwardedMessageContext(pendingForwardContext, hydratedForwardContext);
|
|
1954
|
+
await processAllowedMessage(mergedForwardContext);
|
|
1955
|
+
return;
|
|
1956
|
+
}
|
|
1957
|
+
logger.info('Hydrating forwarded message context from Bitrix24 API', {
|
|
1958
|
+
senderId: msgCtx.senderId,
|
|
1959
|
+
chatId: msgCtx.chatId,
|
|
1960
|
+
messageId: msgCtx.messageId,
|
|
1961
|
+
});
|
|
1962
|
+
await processAllowedMessage(await hydrateForwardedMessageContext(msgCtx));
|
|
1963
|
+
return;
|
|
1964
|
+
}
|
|
1965
|
+
if (canCoalesceDirectMessage(msgCtx, config)) {
|
|
1966
|
+
directTextCoalescer.enqueue(ctx.accountId, msgCtx);
|
|
1967
|
+
return;
|
|
1968
|
+
}
|
|
1969
|
+
await directTextCoalescer.flush(ctx.accountId, msgCtx.chatId);
|
|
1970
|
+
await processAllowedMessage(msgCtx);
|
|
1971
|
+
},
|
|
1972
|
+
onCommand: async (cmdCtx) => {
|
|
1973
|
+
const { commandId, commandName, commandParams, commandText, senderId, dialogId, chatId, chatType, messageId, } = cmdCtx;
|
|
1974
|
+
const isDm = chatType === 'P';
|
|
1975
|
+
const conversation = resolveConversationRef({
|
|
1976
|
+
accountId: ctx.accountId,
|
|
1977
|
+
dialogId,
|
|
1978
|
+
isDirect: isDm,
|
|
1979
|
+
});
|
|
1980
|
+
logger.info('Inbound command', {
|
|
1981
|
+
commandId,
|
|
1982
|
+
commandName,
|
|
1983
|
+
commandParams,
|
|
1984
|
+
commandText,
|
|
1985
|
+
senderId,
|
|
1986
|
+
dialogId,
|
|
1987
|
+
chatId,
|
|
1988
|
+
conversationDialogId: conversation.dialogId,
|
|
1989
|
+
});
|
|
1990
|
+
const sendCtx = {
|
|
1991
|
+
webhookUrl,
|
|
1992
|
+
bot,
|
|
1993
|
+
dialogId: conversation.dialogId,
|
|
1994
|
+
};
|
|
1995
|
+
let runtime;
|
|
1996
|
+
let cfg;
|
|
1997
|
+
try {
|
|
1998
|
+
runtime = getBitrix24Runtime();
|
|
1999
|
+
cfg = runtime.config.loadConfig();
|
|
2000
|
+
}
|
|
2001
|
+
catch (err) {
|
|
2002
|
+
logger.error('Failed to get runtime/config for command', err);
|
|
2003
|
+
return;
|
|
2004
|
+
}
|
|
2005
|
+
const commandMessageId = toMessageId(messageId);
|
|
2006
|
+
const commandSendCtx = commandMessageId
|
|
2007
|
+
? {
|
|
2008
|
+
...sendCtx,
|
|
2009
|
+
commandId,
|
|
2010
|
+
messageId: commandMessageId,
|
|
2011
|
+
commandDialogId: dialogId,
|
|
2012
|
+
}
|
|
2013
|
+
: null;
|
|
2014
|
+
const groupAccess = !isDm
|
|
2015
|
+
? resolveGroupAccess({
|
|
2016
|
+
config,
|
|
2017
|
+
dialogId,
|
|
2018
|
+
chatId,
|
|
2019
|
+
})
|
|
2020
|
+
: null;
|
|
2021
|
+
// Access control
|
|
2022
|
+
let accessResult;
|
|
2023
|
+
try {
|
|
2024
|
+
accessResult = !isDm
|
|
2025
|
+
? await checkGroupAccessWithPairing({
|
|
2026
|
+
senderId,
|
|
2027
|
+
dialogId,
|
|
2028
|
+
chatId,
|
|
2029
|
+
config,
|
|
2030
|
+
runtime,
|
|
2031
|
+
accountId: ctx.accountId,
|
|
2032
|
+
pairingAdapter: bitrix24Plugin.pairing,
|
|
2033
|
+
logger,
|
|
2034
|
+
})
|
|
2035
|
+
: await checkAccessWithPairing({
|
|
2036
|
+
senderId,
|
|
2037
|
+
dialogId,
|
|
2038
|
+
isDirect: isDm,
|
|
2039
|
+
config,
|
|
2040
|
+
runtime,
|
|
2041
|
+
accountId: ctx.accountId,
|
|
2042
|
+
pairingAdapter: bitrix24Plugin.pairing,
|
|
2043
|
+
sendReply: async (text) => {
|
|
2044
|
+
if (commandSendCtx) {
|
|
2045
|
+
await sendService.answerCommandText(commandSendCtx, text, { convertMarkdown: false });
|
|
2046
|
+
return;
|
|
2047
|
+
}
|
|
2048
|
+
await sendService.sendText(sendCtx, text, { convertMarkdown: false });
|
|
2049
|
+
},
|
|
2050
|
+
logger,
|
|
2051
|
+
});
|
|
2052
|
+
}
|
|
2053
|
+
catch (err) {
|
|
2054
|
+
logger.error('Access check failed for command', err);
|
|
2055
|
+
return;
|
|
2056
|
+
}
|
|
2057
|
+
if (!commandMessageId || !commandSendCtx) {
|
|
2058
|
+
logger.warn('Command event has invalid messageId, skipping response', { commandId, messageId, dialogId });
|
|
2059
|
+
return;
|
|
2060
|
+
}
|
|
2061
|
+
if (!isDm && groupAccess?.groupPolicy === 'disabled') {
|
|
2062
|
+
await sendService.answerCommandText(commandSendCtx, groupChatUnsupported(cmdCtx.language), { convertMarkdown: false });
|
|
2063
|
+
return;
|
|
2064
|
+
}
|
|
2065
|
+
await sendService.sendStatus(sendCtx, 'IMBOT_AGENT_ACTION_THINKING', 8);
|
|
2066
|
+
if (accessResult === 'deny') {
|
|
2067
|
+
await sendService.markRead(sendCtx, commandMessageId);
|
|
2068
|
+
await sendService.answerCommandText(commandSendCtx, buildAccessDeniedNotice(cmdCtx.language, isDm ? config.dmPolicy : groupAccess?.groupPolicy, {
|
|
2069
|
+
hasAllowList: isDm
|
|
2070
|
+
? normalizeAllowList(config.allowFrom).length > 0
|
|
2071
|
+
: Boolean(groupAccess?.senderAllowFrom.length),
|
|
2072
|
+
}), { convertMarkdown: false });
|
|
2073
|
+
logger.debug('Command blocked (deny)', { senderId, dialogId });
|
|
2074
|
+
return;
|
|
2075
|
+
}
|
|
2076
|
+
await sendService.markRead(sendCtx, commandMessageId);
|
|
2077
|
+
if (accessResult === 'pairing') {
|
|
2078
|
+
if (!isDm) {
|
|
2079
|
+
await sendService.answerCommandText(commandSendCtx, groupPairingPending(cmdCtx.language), { convertMarkdown: false });
|
|
2080
|
+
}
|
|
2081
|
+
logger.debug(`Command blocked (${accessResult})`, { senderId });
|
|
2082
|
+
return;
|
|
2083
|
+
}
|
|
2084
|
+
await directTextCoalescer.flush(ctx.accountId, conversation.dialogId);
|
|
2085
|
+
if (commandName === 'help' || commandName === 'commands') {
|
|
2086
|
+
await sendService.answerCommandText(commandSendCtx, buildCommandsHelpText(cmdCtx.language, { concise: commandName === 'help' }), { keyboard: DEFAULT_COMMAND_KEYBOARD, convertMarkdown: false });
|
|
2087
|
+
return;
|
|
2088
|
+
}
|
|
2089
|
+
const route = runtime.channel.routing.resolveAgentRoute({
|
|
2090
|
+
cfg,
|
|
2091
|
+
channel: 'bitrix24',
|
|
2092
|
+
accountId: ctx.accountId,
|
|
2093
|
+
peer: conversation.peer,
|
|
2094
|
+
});
|
|
2095
|
+
const slashSessionKey = `bitrix24:slash:${senderId}:${Date.now()}`;
|
|
2096
|
+
const inboundCtx = runtime.channel.reply.finalizeInboundContext({
|
|
2097
|
+
Body: commandText,
|
|
2098
|
+
BodyForAgent: commandText,
|
|
2099
|
+
RawBody: commandText,
|
|
2100
|
+
CommandBody: commandText,
|
|
2101
|
+
CommandAuthorized: true,
|
|
2102
|
+
CommandSource: 'native',
|
|
2103
|
+
CommandTargetSessionKey: buildConversationSessionKey(route.sessionKey, conversation),
|
|
2104
|
+
From: conversation.address,
|
|
2105
|
+
To: `slash:${senderId}`,
|
|
2106
|
+
SessionKey: slashSessionKey,
|
|
2107
|
+
AccountId: route.accountId,
|
|
2108
|
+
ChatType: isDm ? 'direct' : 'group',
|
|
2109
|
+
ConversationLabel: senderId,
|
|
2110
|
+
SenderName: senderId,
|
|
2111
|
+
SenderId: senderId,
|
|
2112
|
+
Provider: 'bitrix24',
|
|
2113
|
+
Surface: 'bitrix24',
|
|
2114
|
+
MessageSid: messageId,
|
|
2115
|
+
Timestamp: Date.now(),
|
|
2116
|
+
WasMentioned: true,
|
|
2117
|
+
OriginatingChannel: 'bitrix24',
|
|
2118
|
+
OriginatingTo: conversation.address,
|
|
2119
|
+
});
|
|
2120
|
+
const replyStatusHeartbeat = createReplyStatusHeartbeat({
|
|
2121
|
+
sendService,
|
|
2122
|
+
sendCtx,
|
|
2123
|
+
config,
|
|
2124
|
+
});
|
|
2125
|
+
let commandReplyDelivered = false;
|
|
2126
|
+
try {
|
|
2127
|
+
await replyStatusHeartbeat.start();
|
|
2128
|
+
await runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
2129
|
+
ctx: inboundCtx,
|
|
2130
|
+
cfg,
|
|
2131
|
+
dispatcherOptions: {
|
|
2132
|
+
deliver: async (payload) => {
|
|
2133
|
+
await replyStatusHeartbeat.stopAndWait();
|
|
2134
|
+
if (payload.text) {
|
|
2135
|
+
const keyboard = extractKeyboardFromPayload(payload) ?? DEFAULT_COMMAND_KEYBOARD;
|
|
2136
|
+
const formattedPayload = normalizeCommandReplyPayload({
|
|
2137
|
+
commandName,
|
|
2138
|
+
commandParams,
|
|
2139
|
+
text: payload.text,
|
|
2140
|
+
language: cmdCtx.language,
|
|
2141
|
+
});
|
|
2142
|
+
if (!commandReplyDelivered) {
|
|
2143
|
+
commandReplyDelivered = true;
|
|
2144
|
+
await sendService.answerCommandText(commandSendCtx, formattedPayload.text, {
|
|
2145
|
+
keyboard,
|
|
2146
|
+
convertMarkdown: formattedPayload.convertMarkdown,
|
|
2147
|
+
});
|
|
2148
|
+
return;
|
|
2149
|
+
}
|
|
2150
|
+
await sendService.sendText(sendCtx, formattedPayload.text, {
|
|
2151
|
+
keyboard,
|
|
2152
|
+
convertMarkdown: formattedPayload.convertMarkdown,
|
|
2153
|
+
});
|
|
2154
|
+
}
|
|
2155
|
+
},
|
|
2156
|
+
onReplyStart: async () => {
|
|
2157
|
+
await replyStatusHeartbeat.start();
|
|
2158
|
+
},
|
|
2159
|
+
onError: (err) => {
|
|
2160
|
+
logger.error('Error delivering command reply to B24', err);
|
|
2161
|
+
},
|
|
2162
|
+
},
|
|
2163
|
+
});
|
|
2164
|
+
}
|
|
2165
|
+
catch (err) {
|
|
2166
|
+
logger.error('Error dispatching command to agent', { commandName, senderId, dialogId, error: err });
|
|
2167
|
+
}
|
|
2168
|
+
finally {
|
|
2169
|
+
replyStatusHeartbeat.stop();
|
|
2170
|
+
}
|
|
2171
|
+
},
|
|
2172
|
+
onJoinChat: async (joinCtx) => {
|
|
2173
|
+
const { senderId, dialogId, chatId, chatType, language } = joinCtx;
|
|
2174
|
+
logger.info('Bot joined chat', { senderId, dialogId, chatId, chatType });
|
|
2175
|
+
if (!dialogId)
|
|
2176
|
+
return;
|
|
2177
|
+
const isGroupChat = chatType === 'chat' || chatType === 'open';
|
|
2178
|
+
const groupAccess = isGroupChat
|
|
2179
|
+
? resolveGroupAccess({ config, dialogId, chatId })
|
|
2180
|
+
: null;
|
|
2181
|
+
if (!isGroupChat && shouldSkipJoinChatWelcome({
|
|
2182
|
+
dialogId,
|
|
2183
|
+
chatType,
|
|
2184
|
+
webhookUrl,
|
|
2185
|
+
dmPolicy: config.dmPolicy,
|
|
2186
|
+
})) {
|
|
2187
|
+
logger.info('Skipping welcome for non-owner dialog in webhookUser mode', { dialogId });
|
|
2188
|
+
return;
|
|
2189
|
+
}
|
|
2190
|
+
const sendCtx = {
|
|
2191
|
+
webhookUrl,
|
|
2192
|
+
bot,
|
|
2193
|
+
dialogId,
|
|
2194
|
+
};
|
|
2195
|
+
if (isGroupChat && (!groupAccess?.groupAllowed || groupAccess.groupPolicy === 'disabled')) {
|
|
2196
|
+
logger.info('Group chat blocked by policy, leaving', { dialogId });
|
|
2197
|
+
try {
|
|
2198
|
+
await sendService.sendText(sendCtx, groupChatUnsupported(language));
|
|
2199
|
+
await api.leaveChat(webhookUrl, bot, dialogId);
|
|
2200
|
+
}
|
|
2201
|
+
catch (err) {
|
|
2202
|
+
logger.error('Failed to leave group chat', err);
|
|
2203
|
+
}
|
|
2204
|
+
return;
|
|
2205
|
+
}
|
|
2206
|
+
if (isGroupChat) {
|
|
2207
|
+
const runtime = getBitrix24Runtime();
|
|
2208
|
+
const inviterAccessResult = await checkGroupAccessPassive({
|
|
2209
|
+
senderId,
|
|
2210
|
+
dialogId,
|
|
2211
|
+
chatId,
|
|
2212
|
+
config,
|
|
2213
|
+
runtime,
|
|
2214
|
+
accountId: ctx.accountId,
|
|
2215
|
+
logger,
|
|
2216
|
+
});
|
|
2217
|
+
if (inviterAccessResult !== 'allow') {
|
|
2218
|
+
const noticeText = inviterAccessResult === 'pairing'
|
|
2219
|
+
? groupPairingPending(language)
|
|
2220
|
+
: buildAccessDeniedNotice(language, groupAccess?.groupPolicy, {
|
|
2221
|
+
hasAllowList: Boolean(groupAccess?.senderAllowFrom.length),
|
|
2222
|
+
});
|
|
2223
|
+
logger.info('Leaving group chat invited by user without access', {
|
|
2224
|
+
dialogId,
|
|
2225
|
+
chatId,
|
|
2226
|
+
senderId,
|
|
2227
|
+
inviterAccessResult,
|
|
2228
|
+
});
|
|
2229
|
+
try {
|
|
2230
|
+
await sendService.sendText(sendCtx, noticeText, { convertMarkdown: false });
|
|
2231
|
+
await api.leaveChat(webhookUrl, bot, dialogId);
|
|
2232
|
+
}
|
|
2233
|
+
catch (err) {
|
|
2234
|
+
logger.error('Failed to leave group chat after inviter access check', err);
|
|
2235
|
+
}
|
|
2236
|
+
return;
|
|
2237
|
+
}
|
|
2238
|
+
logger.info('Group chat enabled by policy, skipping auto-welcome', { dialogId });
|
|
2239
|
+
return;
|
|
2240
|
+
}
|
|
2241
|
+
if (welcomedDialogs.has(dialogId)) {
|
|
2242
|
+
logger.info('Skipping duplicate welcome for already welcomed dialog', { dialogId });
|
|
2243
|
+
return;
|
|
2244
|
+
}
|
|
2245
|
+
// Send welcome message
|
|
2246
|
+
const isPairing = config.dmPolicy === 'pairing';
|
|
2247
|
+
const text = onboardingMessage(language, config.botName ?? 'OpenClaw', config.dmPolicy);
|
|
2248
|
+
try {
|
|
2249
|
+
await sendService.sendText(sendCtx, text, isPairing ? undefined : { keyboard: DEFAULT_COMMAND_KEYBOARD });
|
|
2250
|
+
welcomedDialogs.add(dialogId);
|
|
2251
|
+
logger.info('Welcome message sent', { dialogId });
|
|
2252
|
+
}
|
|
2253
|
+
catch (err) {
|
|
2254
|
+
logger.error('Failed to send welcome message', err);
|
|
2255
|
+
}
|
|
2256
|
+
},
|
|
2257
|
+
onBotDelete: async (_data) => {
|
|
2258
|
+
logger.info('Bot deleted from portal');
|
|
2259
|
+
},
|
|
2260
|
+
});
|
|
2261
|
+
gatewayState = { accountId: ctx.accountId, api, bot, sendService, mediaService, inboundHandler, eventMode };
|
|
2262
|
+
logger.info(`[${ctx.accountId}] Bitrix24 channel started (${eventMode} mode)`);
|
|
2263
|
+
// ─── Mode-specific lifecycle ──────────────────────────────────
|
|
2264
|
+
if (eventMode === 'fetch') {
|
|
2265
|
+
// FETCH mode: start polling loop (blocks until abort)
|
|
2266
|
+
const pollingService = new PollingService({
|
|
2267
|
+
api,
|
|
2268
|
+
webhookUrl,
|
|
2269
|
+
bot,
|
|
2270
|
+
accountId: ctx.accountId,
|
|
2271
|
+
pollingIntervalMs: config.pollingIntervalMs ?? 3000,
|
|
2272
|
+
pollingFastIntervalMs: config.pollingFastIntervalMs ?? 100,
|
|
2273
|
+
withUserEvents: Boolean(config.agentMode),
|
|
2274
|
+
onEvent: async (event) => {
|
|
2275
|
+
const fetchCtx = {
|
|
2276
|
+
webhookUrl,
|
|
2277
|
+
botId: bot.botId,
|
|
2278
|
+
botToken: bot.botToken,
|
|
2279
|
+
};
|
|
2280
|
+
await inboundHandler.handleFetchEvent(event, fetchCtx);
|
|
2281
|
+
},
|
|
2282
|
+
abortSignal: ctx.abortSignal,
|
|
2283
|
+
logger,
|
|
2284
|
+
});
|
|
2285
|
+
try {
|
|
2286
|
+
await pollingService.start();
|
|
2287
|
+
}
|
|
2288
|
+
finally {
|
|
2289
|
+
logger.info(`[${ctx.accountId}] Bitrix24 channel stopping (fetch)`);
|
|
2290
|
+
clearInterval(deniedCleanupTimer);
|
|
2291
|
+
await directTextCoalescer.flushAll();
|
|
2292
|
+
directTextCoalescer.destroy();
|
|
2293
|
+
inboundHandler.destroy();
|
|
2294
|
+
api.destroy();
|
|
2295
|
+
gatewayState = null;
|
|
2296
|
+
}
|
|
2297
|
+
}
|
|
2298
|
+
else {
|
|
2299
|
+
// WEBHOOK mode: keep alive until abort signal
|
|
2300
|
+
return new Promise((resolve) => {
|
|
2301
|
+
ctx.abortSignal.addEventListener('abort', () => {
|
|
2302
|
+
const cleanup = async () => {
|
|
2303
|
+
logger.info(`[${ctx.accountId}] Bitrix24 channel stopping (webhook)`);
|
|
2304
|
+
clearInterval(deniedCleanupTimer);
|
|
2305
|
+
// Flush with timeout to prevent hanging
|
|
2306
|
+
await Promise.race([
|
|
2307
|
+
directTextCoalescer.flushAll(),
|
|
2308
|
+
new Promise((r) => setTimeout(r, 5000)),
|
|
2309
|
+
]);
|
|
2310
|
+
directTextCoalescer.destroy();
|
|
2311
|
+
inboundHandler.destroy();
|
|
2312
|
+
api.destroy();
|
|
2313
|
+
gatewayState = null;
|
|
2314
|
+
};
|
|
2315
|
+
cleanup().catch((err) => {
|
|
2316
|
+
logger.error('Webhook cleanup error', err);
|
|
2317
|
+
}).finally(() => resolve());
|
|
2318
|
+
});
|
|
2319
|
+
});
|
|
2320
|
+
}
|
|
2321
|
+
},
|
|
2322
|
+
},
|
|
2323
|
+
};
|
|
2324
|
+
//# sourceMappingURL=channel.js.map
|