neuroalpha 0.6.13 → 0.7.1
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 +14 -10
- package/bin/cli.js +79 -69
- package/docs//320/230/320/235/320/241/320/242/320/240/320/243/320/232/320/246/320/230/320/257.md +2 -2
- package/package.json +4 -5
- package/public/activate.html +41 -19
- package/public/activate.js +81 -10
- package/public/app.js +471 -166
- package/public/index.html +9 -39
- package/public/styles.css +42 -3
- package/server/api/routes.js +595 -906
- package/server/auth/index.js +583 -761
- package/server/brokers/index.js +14 -3
- package/server/config.js +73 -53
- package/server/index.js +186 -212
- package/server/llm/local-engine.js +843 -0
- package/server/llm/providers.js +45 -34
- package/server/model/live.js +2 -1
- package/server/notify/index.js +304 -466
- package/server/strategist/index.js +4 -1
- package/deploy/docs/01-central.md +0 -96
- package/deploy/docs/02-cloud-instances.md +0 -130
- package/deploy/docs/03-desktop-build.md +0 -92
- package/deploy/docs/04-client-guide.md +0 -101
- package/deploy/docs/05-operations.md +0 -109
- package/deploy/docs/06-env-reference.md +0 -91
- package/deploy/docs/07-stress-test.md +0 -253
- package/deploy/docs/pdf/01-central.pdf +0 -0
- package/deploy/docs/pdf/02-cloud-instances.pdf +0 -0
- package/deploy/docs/pdf/03-desktop-build.pdf +0 -0
- package/deploy/docs/pdf/04-client-guide.pdf +0 -0
- package/deploy/docs/pdf/05-operations.pdf +0 -0
- package/deploy/docs/pdf/06-env-reference.pdf +0 -0
- package/deploy/docs/pdf/07-stress-test.pdf +0 -0
- package/deploy/install.sh +0 -128
- package/deploy/k8s/instance.yaml +0 -147
- package/public/admin.html +0 -604
- package/public/landing.html +0 -1207
- package/public/legal.html +0 -134
- package/scripts/set-admin-password.mjs +0 -30
- package/server/billing/yookassa.js +0 -142
- package/server/instance/agent.js +0 -259
- package/server/instance/central.js +0 -514
- package/server/instance/identity.js +0 -79
- package/server/instance/provision.js +0 -166
- package/server/instance/signing.js +0 -71
- package/server/role.js +0 -44
package/server/notify/index.js
CHANGED
|
@@ -1,466 +1,304 @@
|
|
|
1
|
-
// Copyright (c) 2026 Roman A. Perepelkin. All rights reserved. Proprietary software.
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
import
|
|
6
|
-
import
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import { encryptSecret, decryptSecret, getUserById, runWithUser,
|
|
10
|
-
import { round } from '../util.js';
|
|
11
|
-
|
|
12
|
-
let actionHandlers = { execute: null, reject: null };
|
|
13
|
-
export function registerBrokerHandlers(h) { actionHandlers = h; }
|
|
14
|
-
|
|
15
|
-
let chatHandlers = { ask: null
|
|
16
|
-
export function registerChatHandlers(h) { chatHandlers = h; }
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
] : null;
|
|
306
|
-
for (let i = 0; i < chunks.length; i++) {
|
|
307
|
-
const isLast = i === chunks.length - 1;
|
|
308
|
-
await sendFn(`🤖 Стратег:\n\n${chunks[i]}`, isLast ? buttons : null);
|
|
309
|
-
}
|
|
310
|
-
} catch (e) {
|
|
311
|
-
await sendFn(`⚠️ Стратег: ${e.message}`);
|
|
312
|
-
}
|
|
313
|
-
}
|
|
314
|
-
let instanceActionHandler = null;
|
|
315
|
-
export function registerInstanceActionHandler(fn) { instanceActionHandler = fn; }
|
|
316
|
-
|
|
317
|
-
async function handleCallbackAction(data, replyFn, { channel, chatId } = {}) {
|
|
318
|
-
if (String(data || '').startsWith('ix:')) {
|
|
319
|
-
if (!instanceActionHandler) return;
|
|
320
|
-
try {
|
|
321
|
-
await replyFn(await instanceActionHandler(String(data).slice(3), { channel, chatId }));
|
|
322
|
-
} catch (e) {
|
|
323
|
-
await replyFn(`Ошибка: ${e.message}`);
|
|
324
|
-
}
|
|
325
|
-
return;
|
|
326
|
-
}
|
|
327
|
-
if (String(data || '').startsWith('aiact:')) {
|
|
328
|
-
const [, decision, uidStr, msgIdStr, callId] = String(data).split(':');
|
|
329
|
-
const u = getUserById(Number(uidStr));
|
|
330
|
-
if (!u || !chatHandlers.resolve) return;
|
|
331
|
-
const boundChat = channel === 'max' ? u.max_chat_id : u.tg_chat_id;
|
|
332
|
-
if (!boundChat || !chatId || String(boundChat) !== String(chatId)) {
|
|
333
|
-
await replyFn('⛔ Это действие доступно только из привязанного чата владельца.');
|
|
334
|
-
return;
|
|
335
|
-
}
|
|
336
|
-
try {
|
|
337
|
-
const res = await runWithUser(u, () => chatHandlers.resolve(Number(msgIdStr), String(callId), decision));
|
|
338
|
-
const chunks = formatMessengerChunks(res?.text || '');
|
|
339
|
-
for (const ch of chunks) await replyFn(`🤖 Стратег:\n\n${ch}`);
|
|
340
|
-
} catch (e) {
|
|
341
|
-
await replyFn(`⚠️ ${e.message}`);
|
|
342
|
-
}
|
|
343
|
-
return;
|
|
344
|
-
}
|
|
345
|
-
const [act, uidStr, accId] = String(data || '').split(':');
|
|
346
|
-
const u = getUserById(Number(uidStr));
|
|
347
|
-
if (!u || !accId) return;
|
|
348
|
-
// Кнопка должна прийти из того же чата, который привязан к владельцу счёта (данные кнопки можно подделать)
|
|
349
|
-
const boundChat = channel === 'max' ? u.max_chat_id : u.tg_chat_id;
|
|
350
|
-
if (!boundChat || !chatId || String(boundChat) !== String(chatId)) {
|
|
351
|
-
await replyFn('⛔ Это действие доступно только из чата, привязанного к владельцу счёта.');
|
|
352
|
-
return;
|
|
353
|
-
}
|
|
354
|
-
try {
|
|
355
|
-
if (act === 'exec' && actionHandlers.execute) {
|
|
356
|
-
await runWithUser(u, () => actionHandlers.execute(accId));
|
|
357
|
-
await replyFn(`✓ Заявки по счёту отправлены брокеру.`);
|
|
358
|
-
} else if (act === 'rej' && actionHandlers.reject) {
|
|
359
|
-
await runWithUser(u, () => actionHandlers.reject(accId));
|
|
360
|
-
await replyFn(`✕ Пакет заявок отклонён.`);
|
|
361
|
-
}
|
|
362
|
-
} catch (e) {
|
|
363
|
-
await replyFn(`Ошибка: ${e.message}`);
|
|
364
|
-
}
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
let tgOffset = 0;
|
|
368
|
-
let maxMarker = null;
|
|
369
|
-
|
|
370
|
-
async function pollTelegram() {
|
|
371
|
-
const { tgToken } = getNotifyConfig();
|
|
372
|
-
if (!tgToken) return;
|
|
373
|
-
try {
|
|
374
|
-
const r = await fetch(`https://api.telegram.org/bot${tgToken}/getUpdates?offset=${tgOffset}&timeout=1`, { signal: AbortSignal.timeout(8000) });
|
|
375
|
-
if (!r.ok) return;
|
|
376
|
-
const j = await r.json();
|
|
377
|
-
for (const upd of j.result || []) {
|
|
378
|
-
tgOffset = Math.max(tgOffset, upd.update_id + 1);
|
|
379
|
-
if (upd.message?.text) {
|
|
380
|
-
const chatId = upd.message.chat.id;
|
|
381
|
-
const txt = upd.message.text.trim();
|
|
382
|
-
const from = upd.message.from || {};
|
|
383
|
-
const loginUser = handleBotLogin(txt, 'tg', chatId, `${from.first_name || ''} ${from.last_name || ''}`.trim(), from.username || '');
|
|
384
|
-
if (loginUser) {
|
|
385
|
-
await sendTelegram(chatId, `✓ Вход в НейроАльфа выполнен (${loginUser.name})!\n\nВаш персональный API-ключ:\n${loginUser.api_key}\n\nЗапуск на компьютере:\nnpx neuroalpha --key ${loginUser.api_key}\n\nВернитесь в браузер — личный кабинет уже открыт.`);
|
|
386
|
-
continue;
|
|
387
|
-
}
|
|
388
|
-
const u = tryLinkByCode(txt, 'tg', chatId);
|
|
389
|
-
if (u) {
|
|
390
|
-
await sendTelegram(chatId, `✓ Telegram успешно привязан к аккаунту НейроАльфа (${u.email}). Сюда будут приходить сигналы и кнопки подтверждения заявок, а также вы можете писать сюда любые вопросы ИИ-Стратегу (например: «Прогноз», «Портфель», «Что по SBER?»).`);
|
|
391
|
-
continue;
|
|
392
|
-
}
|
|
393
|
-
setImmediate(() => {
|
|
394
|
-
handleMessengerChat(txt, 'tg', chatId, (msg, btns) => sendTelegram(chatId, msg, btns)).catch(() => {});
|
|
395
|
-
});
|
|
396
|
-
} else if (upd.callback_query?.data) {
|
|
397
|
-
const chatId = upd.callback_query.message?.chat?.id;
|
|
398
|
-
const fromId = upd.callback_query.from?.id;
|
|
399
|
-
// в личном чате с ботом id чата совпадает с id нажавшего пользователя
|
|
400
|
-
const verifiedChat = chatId && fromId && String(chatId) === String(fromId) ? chatId : null;
|
|
401
|
-
await handleCallbackAction(upd.callback_query.data, (msg) => sendTelegram(chatId, msg), { channel: 'tg', chatId: verifiedChat });
|
|
402
|
-
}
|
|
403
|
-
}
|
|
404
|
-
} catch { /* ignore */ }
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
async function processMaxUpdate(upd) {
|
|
408
|
-
const chatId = upd.chat_id || upd.message?.recipient?.chat_id || upd.message?.sender?.user_id || upd.user?.user_id;
|
|
409
|
-
const sender = upd.message?.sender || upd.user || {};
|
|
410
|
-
const txt = String(upd.message?.body?.text || upd.payload || '').trim();
|
|
411
|
-
if (txt && chatId) {
|
|
412
|
-
const loginUser = handleBotLogin(txt, 'max', chatId, sender.name || '', sender.username || '');
|
|
413
|
-
if (loginUser) {
|
|
414
|
-
await sendMax(chatId, `✓ Вход в НейроАльфа выполнен (${loginUser.name})!\n\nВаш персональный API-ключ:\n${loginUser.api_key}\n\nЗапуск на компьютере:\nnpx neuroalpha --key ${loginUser.api_key}\n\nВернитесь в браузер — личный кабинет уже открыт.`);
|
|
415
|
-
return;
|
|
416
|
-
}
|
|
417
|
-
const u = tryLinkByCode(txt, 'max', chatId);
|
|
418
|
-
if (u) {
|
|
419
|
-
await sendMax(chatId, `✓ Мессенджер MAX успешно привязан к аккаунту НейроАльфа (${u.email}). Сюда будут приходить сигналы и кнопки подтверждения заявок, а также вы можете писать сюда любые вопросы ИИ-Стратегу (например: «Прогноз», «Портфель», «Что по SBER?»).`);
|
|
420
|
-
return;
|
|
421
|
-
}
|
|
422
|
-
if (!upd.callback?.payload) {
|
|
423
|
-
setImmediate(() => {
|
|
424
|
-
handleMessengerChat(txt, 'max', chatId, (msg, btns) => sendMax(chatId, msg, btns)).catch(() => {});
|
|
425
|
-
});
|
|
426
|
-
}
|
|
427
|
-
}
|
|
428
|
-
if (upd.callback?.payload) {
|
|
429
|
-
const cbChatId = upd.chat_id || upd.message?.recipient?.chat_id || upd.callback?.user?.user_id;
|
|
430
|
-
await handleCallbackAction(upd.callback.payload, (msg) => sendMax(cbChatId, msg), { channel: 'max', chatId: cbChatId });
|
|
431
|
-
}
|
|
432
|
-
}
|
|
433
|
-
|
|
434
|
-
// Вебхук MAX принимается только с секретом (заголовок X-Max-Bot-Api-Secret = MAX_WEBHOOK_SECRET).
|
|
435
|
-
// Без настроенного секрета вебхук выключен — обновления MAX забираются опросом (pollMax).
|
|
436
|
-
export async function handleMaxWebhook(body = {}, headers = {}) {
|
|
437
|
-
const secret = String(process.env.MAX_WEBHOOK_SECRET || '').trim();
|
|
438
|
-
const got = String(headers['x-max-bot-api-secret'] || '').trim();
|
|
439
|
-
const a = Buffer.from(got);
|
|
440
|
-
const b = Buffer.from(secret);
|
|
441
|
-
const okSecret = secret && a.length === b.length && crypto.timingSafeEqual(a, b);
|
|
442
|
-
if (!okSecret) throw Object.assign(new Error('Вебхук MAX отключён или секрет неверен'), { status: 403 });
|
|
443
|
-
const list = Array.isArray(body.updates) ? body.updates : (body.update_type ? [body] : []);
|
|
444
|
-
for (const upd of list) await processMaxUpdate(upd);
|
|
445
|
-
return { ok: true };
|
|
446
|
-
}
|
|
447
|
-
|
|
448
|
-
async function pollMax() {
|
|
449
|
-
const { maxToken } = getNotifyConfig();
|
|
450
|
-
if (!maxToken) return;
|
|
451
|
-
try {
|
|
452
|
-
const r = await maxRequest(`/updates?timeout=1${maxMarker ? `&marker=${maxMarker}` : ''}`, { token: maxToken, timeoutMs: 8000 });
|
|
453
|
-
if (!r.ok || !r.json) return;
|
|
454
|
-
if (r.json.marker) maxMarker = r.json.marker;
|
|
455
|
-
for (const upd of r.json.updates || []) await processMaxUpdate(upd);
|
|
456
|
-
} catch { /* ignore */ }
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
export function startNotifyPolling() {
|
|
460
|
-
if (IS_INSTANCE) return;
|
|
461
|
-
const timer = setInterval(() => {
|
|
462
|
-
pollTelegram();
|
|
463
|
-
pollMax();
|
|
464
|
-
}, 6000);
|
|
465
|
-
timer.unref?.();
|
|
466
|
-
}
|
|
1
|
+
// Copyright (c) 2026 Roman A. Perepelkin. All rights reserved. Proprietary software.
|
|
2
|
+
// Уведомления и диалог со Стратегом в Telegram и MAX для торгового терминала:
|
|
3
|
+
// 1) «Из коробки» по токену личного кабинета — через центрального бота портала (/api/relay/notify и /api/relay/poll);
|
|
4
|
+
// 2) Либо напрямую через личного бота пользователя, если он указал свой токен Telegram / MAX в настройках терминала.
|
|
5
|
+
import crypto from 'node:crypto';
|
|
6
|
+
import https from 'node:https';
|
|
7
|
+
import { TRUSTED_CA } from '../tls-ca.js';
|
|
8
|
+
import { kvGet, kvSet } from '../db.js';
|
|
9
|
+
import { encryptSecret, decryptSecret, getUserById, runWithUser, getStoredCabinetCredentials } from '../auth/index.js';
|
|
10
|
+
import { round } from '../util.js';
|
|
11
|
+
|
|
12
|
+
let actionHandlers = { execute: null, reject: null };
|
|
13
|
+
export function registerBrokerHandlers(h) { actionHandlers = h; }
|
|
14
|
+
|
|
15
|
+
let chatHandlers = { ask: null };
|
|
16
|
+
export function registerChatHandlers(h) { chatHandlers = h; }
|
|
17
|
+
|
|
18
|
+
function maxRequest(urlPath, { method = 'GET', token = '', body = null, timeoutMs = 12000 } = {}) {
|
|
19
|
+
return new Promise((resolve, reject) => {
|
|
20
|
+
const u = new URL(urlPath, 'https://platform-api2.max.ru');
|
|
21
|
+
const payload = body ? Buffer.from(typeof body === 'string' ? body : JSON.stringify(body)) : null;
|
|
22
|
+
const req = https.request({
|
|
23
|
+
hostname: u.hostname,
|
|
24
|
+
port: u.port || 443,
|
|
25
|
+
path: u.pathname + u.search,
|
|
26
|
+
method,
|
|
27
|
+
ca: TRUSTED_CA,
|
|
28
|
+
rejectUnauthorized: true,
|
|
29
|
+
headers: {
|
|
30
|
+
Authorization: token,
|
|
31
|
+
Accept: 'application/json',
|
|
32
|
+
...(payload ? { 'Content-Type': 'application/json', 'Content-Length': payload.length } : {}),
|
|
33
|
+
},
|
|
34
|
+
timeout: timeoutMs,
|
|
35
|
+
}, (res) => {
|
|
36
|
+
let raw = '';
|
|
37
|
+
res.setEncoding('utf8');
|
|
38
|
+
res.on('data', (chunk) => { raw += chunk; });
|
|
39
|
+
res.on('end', () => {
|
|
40
|
+
let json = null;
|
|
41
|
+
try { json = JSON.parse(raw); } catch { /* ignore */ }
|
|
42
|
+
resolve({ ok: res.statusCode >= 200 && res.statusCode < 300, status: res.statusCode, json, text: raw });
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
req.on('timeout', () => req.destroy(new Error('MAX API timeout')));
|
|
46
|
+
req.on('error', reject);
|
|
47
|
+
if (payload) req.write(payload);
|
|
48
|
+
req.end();
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function getNotifyConfig() {
|
|
53
|
+
const saved = kvGet('notifyConfig') || {};
|
|
54
|
+
const tgToken = decryptSecret(saved.tgToken || '') || String(process.env.TELEGRAM_BOT_TOKEN || '').trim();
|
|
55
|
+
const tgChatId = String(saved.tgChatId || process.env.TELEGRAM_CHAT_ID || '').trim();
|
|
56
|
+
const maxToken = decryptSecret(saved.maxToken || '') || String(process.env.MAX_BOT_TOKEN || '').trim();
|
|
57
|
+
const maxChatId = String(saved.maxChatId || process.env.MAX_CHAT_ID || '').trim();
|
|
58
|
+
const usePortalRelay = saved.usePortalRelay !== false;
|
|
59
|
+
return { tgToken, tgChatId, maxToken, maxChatId, usePortalRelay };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function publicNotifyConfig() {
|
|
63
|
+
const c = getNotifyConfig();
|
|
64
|
+
return {
|
|
65
|
+
hasDirectTg: Boolean(c.tgToken),
|
|
66
|
+
tgChatId: c.tgChatId,
|
|
67
|
+
hasDirectMax: Boolean(c.maxToken),
|
|
68
|
+
maxChatId: c.maxChatId,
|
|
69
|
+
usePortalRelay: c.usePortalRelay,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function saveNotifyConfig(input = {}) {
|
|
74
|
+
const prev = kvGet('notifyConfig') || {};
|
|
75
|
+
const next = {
|
|
76
|
+
tgToken: input.clearTg ? '' : (input.tgToken && String(input.tgToken).trim() ? encryptSecret(input.tgToken) : (prev.tgToken || '')),
|
|
77
|
+
tgChatId: String(input.tgChatId ?? prev.tgChatId ?? '').trim(),
|
|
78
|
+
maxToken: input.clearMax ? '' : (input.maxToken && String(input.maxToken).trim() ? encryptSecret(input.maxToken) : (prev.maxToken || '')),
|
|
79
|
+
maxChatId: String(input.maxChatId ?? prev.maxChatId ?? '').trim(),
|
|
80
|
+
usePortalRelay: input.usePortalRelay !== undefined ? Boolean(input.usePortalRelay) : (prev.usePortalRelay !== false),
|
|
81
|
+
};
|
|
82
|
+
kvSet('notifyConfig', next);
|
|
83
|
+
return publicNotifyConfig();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function sendDirectTelegram(chatId, text, inlineButtons = null) {
|
|
87
|
+
const { tgToken } = getNotifyConfig();
|
|
88
|
+
if (!tgToken || !chatId) return false;
|
|
89
|
+
const body = { chat_id: String(chatId), text: String(text || '').slice(0, 4000) };
|
|
90
|
+
if (Array.isArray(inlineButtons) && inlineButtons.length) {
|
|
91
|
+
body.reply_markup = { inline_keyboard: inlineButtons };
|
|
92
|
+
}
|
|
93
|
+
const r = await fetch(`https://api.telegram.org/bot${tgToken}/sendMessage`, {
|
|
94
|
+
method: 'POST',
|
|
95
|
+
headers: { 'Content-Type': 'application/json' },
|
|
96
|
+
body: JSON.stringify(body),
|
|
97
|
+
signal: AbortSignal.timeout(12000),
|
|
98
|
+
}).catch(() => null);
|
|
99
|
+
return Boolean(r?.ok);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function sendDirectMax(chatId, text, inlineButtons = null) {
|
|
103
|
+
const { maxToken } = getNotifyConfig();
|
|
104
|
+
if (!maxToken || !chatId) return false;
|
|
105
|
+
const payload = { text: String(text || '').slice(0, 4000) };
|
|
106
|
+
if (Array.isArray(inlineButtons) && inlineButtons.length) {
|
|
107
|
+
payload.attachments = [{
|
|
108
|
+
type: 'inline_keyboard',
|
|
109
|
+
payload: {
|
|
110
|
+
buttons: inlineButtons.map((row) => row.map((b) => ({
|
|
111
|
+
type: 'callback',
|
|
112
|
+
text: b.text,
|
|
113
|
+
payload: b.callback_data,
|
|
114
|
+
}))),
|
|
115
|
+
},
|
|
116
|
+
}];
|
|
117
|
+
}
|
|
118
|
+
const r = await maxRequest(`/messages?chat_id=${encodeURIComponent(String(chatId))}`, {
|
|
119
|
+
method: 'POST',
|
|
120
|
+
token: maxToken,
|
|
121
|
+
body: payload,
|
|
122
|
+
}).catch(() => null);
|
|
123
|
+
return Boolean(r?.ok);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function sendViaPortalRelay(text, buttons = null) {
|
|
127
|
+
const { usePortalRelay } = getNotifyConfig();
|
|
128
|
+
if (!usePortalRelay) return { tg: false, max: false };
|
|
129
|
+
const { token, portalUrl } = getStoredCabinetCredentials();
|
|
130
|
+
if (!token || !portalUrl) return { tg: false, max: false };
|
|
131
|
+
try {
|
|
132
|
+
const r = await fetch(`${portalUrl}/api/relay/notify`, {
|
|
133
|
+
method: 'POST',
|
|
134
|
+
headers: {
|
|
135
|
+
'Content-Type': 'application/json',
|
|
136
|
+
Authorization: `Bearer ${token}`,
|
|
137
|
+
},
|
|
138
|
+
body: JSON.stringify({ text, ...(buttons ? { buttons } : {}) }),
|
|
139
|
+
signal: AbortSignal.timeout(12000),
|
|
140
|
+
});
|
|
141
|
+
const j = await r.json().catch(() => ({}));
|
|
142
|
+
return { tg: Boolean(j?.tg), max: Boolean(j?.max) };
|
|
143
|
+
} catch {
|
|
144
|
+
return { tg: false, max: false };
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export async function notifyUser(_userId, text, relayButtons = null) {
|
|
149
|
+
const c = getNotifyConfig();
|
|
150
|
+
const hasDirect = Boolean((c.tgToken && c.tgChatId) || (c.maxToken && c.maxChatId));
|
|
151
|
+
if (hasDirect) {
|
|
152
|
+
const inline = Array.isArray(relayButtons) && relayButtons.length
|
|
153
|
+
? [relayButtons.map((b) => ({
|
|
154
|
+
text: b.text,
|
|
155
|
+
callback_data: `local_${b.action}_${b.accountId}_${b.batchId || ''}`.slice(0, 64),
|
|
156
|
+
}))]
|
|
157
|
+
: null;
|
|
158
|
+
const [tg, max] = await Promise.all([
|
|
159
|
+
c.tgToken && c.tgChatId ? sendDirectTelegram(c.tgChatId, text, inline) : false,
|
|
160
|
+
c.maxToken && c.maxChatId ? sendDirectMax(c.maxChatId, text, inline) : false,
|
|
161
|
+
]);
|
|
162
|
+
if (tg || max) return { tg, max };
|
|
163
|
+
}
|
|
164
|
+
return sendViaPortalRelay(text, relayButtons);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export async function notifyBrokerBatch(acc, batch) {
|
|
168
|
+
if (!batch?.orders?.length) return;
|
|
169
|
+
const lines = batch.orders.map((o, idx) => {
|
|
170
|
+
const actLbl = { buy: 'ПОКУПКА', sell: 'ПРОДАЖА', short: 'ШОРТ', cover: 'ЗАКРЫТИЕ ШОРТА' }[o.action] || o.action;
|
|
171
|
+
return `${idx + 1}. ${actLbl} ${o.symbol}: ${o.lots} лот. (~${Math.round(o.amountRub || 0).toLocaleString('ru-RU')} ₽) по ${o.limitPrice} ₽`;
|
|
172
|
+
});
|
|
173
|
+
const header = acc.mode === 'auto'
|
|
174
|
+
? `⚡ Автоисполнение заявок · счёт «${acc.name}»`
|
|
175
|
+
: `📋 Подтвердите пакет заявок · счёт «${acc.name}»`;
|
|
176
|
+
const text = `${header}\n\n${lines.join('\n')}`;
|
|
177
|
+
const buttons = acc.mode === 'confirm'
|
|
178
|
+
? [
|
|
179
|
+
{ action: 'exec', accountId: acc.id, batchId: batch.id || '', text: '✓ Исполнить пакет' },
|
|
180
|
+
{ action: 'rej', accountId: acc.id, batchId: batch.id || '', text: '✕ Отклонить' },
|
|
181
|
+
]
|
|
182
|
+
: null;
|
|
183
|
+
await notifyUser(1, text, buttons).catch(() => {});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ---------- Выполнение входящих команд (из релея портала или прямого личного бота) ----------
|
|
187
|
+
const executedRelayIds = new Set();
|
|
188
|
+
async function processIncomingCommand(cmd) {
|
|
189
|
+
if (!cmd) return;
|
|
190
|
+
if (cmd.id) {
|
|
191
|
+
if (executedRelayIds.has(cmd.id)) return;
|
|
192
|
+
executedRelayIds.add(cmd.id);
|
|
193
|
+
if (executedRelayIds.size > 500) {
|
|
194
|
+
const first = executedRelayIds.values().next().value;
|
|
195
|
+
executedRelayIds.delete(first);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
await runWithUser(getUserById(1), async () => {
|
|
199
|
+
if (cmd.type === 'batch') {
|
|
200
|
+
if (cmd.action === 'exec' && actionHandlers.execute) {
|
|
201
|
+
try {
|
|
202
|
+
const st = await actionHandlers.execute(cmd.accountId);
|
|
203
|
+
const acc = st?.accounts?.find((a) => a.id === cmd.accountId);
|
|
204
|
+
const res = acc?.pendingBatch?.results || [];
|
|
205
|
+
const ok = res.filter((r) => r.ok).length;
|
|
206
|
+
await notifyUser(1, `✓ Счёт «${acc?.name || cmd.accountId}»: отправлено брокеру ${ok} из ${res.length} заявок.`);
|
|
207
|
+
} catch (e) {
|
|
208
|
+
await notifyUser(1, `⚠ Ошибка исполнения пакета: ${e.message}`);
|
|
209
|
+
}
|
|
210
|
+
} else if (cmd.action === 'rej' && actionHandlers.reject) {
|
|
211
|
+
try {
|
|
212
|
+
actionHandlers.reject(cmd.accountId);
|
|
213
|
+
await notifyUser(1, `✕ Пакет заявок по счёту отклонён.`);
|
|
214
|
+
} catch (e) {
|
|
215
|
+
await notifyUser(1, `⚠ ${e.message}`);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
} else if (cmd.type === 'chat' && cmd.text && chatHandlers.ask) {
|
|
219
|
+
const res = await chatHandlers.ask(cmd.text, { channel: cmd.channel || 'tg' });
|
|
220
|
+
if (res?.text) {
|
|
221
|
+
await notifyUser(1, `🤖 Стратег:\n\n${String(res.text).slice(0, 3500)}`);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
let pollingStarted = false;
|
|
228
|
+
let directTgOffset = 0;
|
|
229
|
+
|
|
230
|
+
export function startNotifyPolling() {
|
|
231
|
+
if (pollingStarted) return;
|
|
232
|
+
pollingStarted = true;
|
|
233
|
+
|
|
234
|
+
// 1) Опрос очереди ответных команд через портал (только если включён релей и есть токен ЛК)
|
|
235
|
+
const pollPortalRelay = async () => {
|
|
236
|
+
const c = getNotifyConfig();
|
|
237
|
+
const { token, portalUrl } = getStoredCabinetCredentials();
|
|
238
|
+
const hasDirect = Boolean((c.tgToken && c.tgChatId) || (c.maxToken && c.maxChatId));
|
|
239
|
+
if (c.usePortalRelay && !hasDirect && token && portalUrl) {
|
|
240
|
+
try {
|
|
241
|
+
const r = await fetch(`${portalUrl}/api/relay/poll`, {
|
|
242
|
+
method: 'POST',
|
|
243
|
+
headers: {
|
|
244
|
+
'Content-Type': 'application/json',
|
|
245
|
+
Authorization: `Bearer ${token}`,
|
|
246
|
+
},
|
|
247
|
+
body: JSON.stringify({ ack: [...executedRelayIds].slice(-20) }),
|
|
248
|
+
signal: AbortSignal.timeout(12000),
|
|
249
|
+
});
|
|
250
|
+
const j = await r.json().catch(() => null);
|
|
251
|
+
if (Array.isArray(j?.commands)) {
|
|
252
|
+
for (const cmd of j.commands) {
|
|
253
|
+
await processIncomingCommand(cmd).catch(() => {});
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
} catch { /* silent */ }
|
|
257
|
+
}
|
|
258
|
+
const t = setTimeout(pollPortalRelay, 12000);
|
|
259
|
+
t.unref?.();
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
// 2) Прямой опрос личного Telegram-бота (если пользователь указал свой токен в настройках терминала)
|
|
263
|
+
const pollDirectTg = async () => {
|
|
264
|
+
const c = getNotifyConfig();
|
|
265
|
+
if (c.tgToken) {
|
|
266
|
+
try {
|
|
267
|
+
const r = await fetch(`https://api.telegram.org/bot${c.tgToken}/getUpdates?timeout=15&offset=${directTgOffset}`, {
|
|
268
|
+
signal: AbortSignal.timeout(22000),
|
|
269
|
+
});
|
|
270
|
+
const j = await r.json().catch(() => null);
|
|
271
|
+
if (j?.ok && Array.isArray(j.result)) {
|
|
272
|
+
for (const upd of j.result) {
|
|
273
|
+
directTgOffset = Math.max(directTgOffset, (upd.update_id || 0) + 1);
|
|
274
|
+
if (upd.callback_query?.data?.startsWith('local_')) {
|
|
275
|
+
const [, action, accountId, batchId] = upd.callback_query.data.split('_');
|
|
276
|
+
await fetch(`https://api.telegram.org/bot${c.tgToken}/answerCallbackQuery`, {
|
|
277
|
+
method: 'POST',
|
|
278
|
+
headers: { 'Content-Type': 'application/json' },
|
|
279
|
+
body: JSON.stringify({ callback_query_id: upd.callback_query.id }),
|
|
280
|
+
}).catch(() => {});
|
|
281
|
+
await processIncomingCommand({ id: `dtg_${upd.update_id}`, type: 'batch', action, accountId, batchId });
|
|
282
|
+
} else if (upd.message?.text && upd.message?.chat?.id) {
|
|
283
|
+
const chatId = String(upd.message.chat.id);
|
|
284
|
+
if (!c.tgChatId) saveNotifyConfig({ tgChatId: chatId });
|
|
285
|
+
if (!c.tgChatId || c.tgChatId === chatId) {
|
|
286
|
+
const txt = upd.message.text.trim();
|
|
287
|
+
if (txt === '/start') {
|
|
288
|
+
await sendDirectTelegram(chatId, `✓ Личный бот НейроАльфа подключён (Chat ID: ${chatId}).`);
|
|
289
|
+
} else {
|
|
290
|
+
await processIncomingCommand({ id: `dtgm_${upd.update_id}`, type: 'chat', text: txt, channel: 'tg' });
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
} catch { /* silent */ }
|
|
297
|
+
}
|
|
298
|
+
const t = setTimeout(pollDirectTg, 3000);
|
|
299
|
+
t.unref?.();
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
pollPortalRelay();
|
|
303
|
+
pollDirectTg();
|
|
304
|
+
}
|