neuroalpha 0.6.13 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +14 -10
  2. package/bin/cli.js +79 -69
  3. 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
  4. package/package.json +4 -5
  5. package/public/activate.html +41 -19
  6. package/public/activate.js +81 -10
  7. package/public/app.js +300 -152
  8. package/public/index.html +4 -37
  9. package/server/api/routes.js +570 -906
  10. package/server/auth/index.js +576 -761
  11. package/server/brokers/index.js +1 -2
  12. package/server/config.js +73 -53
  13. package/server/index.js +186 -212
  14. package/server/llm/local-engine.js +843 -0
  15. package/server/llm/providers.js +45 -34
  16. package/server/notify/index.js +304 -466
  17. package/deploy/docs/01-central.md +0 -96
  18. package/deploy/docs/02-cloud-instances.md +0 -130
  19. package/deploy/docs/03-desktop-build.md +0 -92
  20. package/deploy/docs/04-client-guide.md +0 -101
  21. package/deploy/docs/05-operations.md +0 -109
  22. package/deploy/docs/06-env-reference.md +0 -91
  23. package/deploy/docs/07-stress-test.md +0 -253
  24. package/deploy/docs/pdf/01-central.pdf +0 -0
  25. package/deploy/docs/pdf/02-cloud-instances.pdf +0 -0
  26. package/deploy/docs/pdf/03-desktop-build.pdf +0 -0
  27. package/deploy/docs/pdf/04-client-guide.pdf +0 -0
  28. package/deploy/docs/pdf/05-operations.pdf +0 -0
  29. package/deploy/docs/pdf/06-env-reference.pdf +0 -0
  30. package/deploy/docs/pdf/07-stress-test.pdf +0 -0
  31. package/deploy/install.sh +0 -128
  32. package/deploy/k8s/instance.yaml +0 -147
  33. package/public/admin.html +0 -604
  34. package/public/landing.html +0 -1207
  35. package/public/legal.html +0 -134
  36. package/scripts/set-admin-password.mjs +0 -30
  37. package/server/billing/yookassa.js +0 -142
  38. package/server/instance/agent.js +0 -259
  39. package/server/instance/central.js +0 -514
  40. package/server/instance/identity.js +0 -79
  41. package/server/instance/provision.js +0 -166
  42. package/server/instance/signing.js +0 -71
  43. package/server/role.js +0 -44
@@ -1,466 +1,304 @@
1
- // Copyright (c) 2026 Roman A. Perepelkin. All rights reserved. Proprietary software.
2
- import crypto from 'node:crypto';
3
- import https from 'node:https';
4
- import { TRUSTED_CA } from '../tls-ca.js';
5
- import { IS_INSTANCE } from '../role.js';
6
- import { getIdentity } from '../instance/identity.js';
7
- import { relayNotifyToCentral } from '../instance/agent.js';
8
- import { db, kvGet, kvSet, countUserChatToday } from '../db.js';
9
- import { encryptSecret, decryptSecret, getUserById, runWithUser, generateApiKey, hashPassword, ensureApiKey, effectivePlan } 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, resolve: null };
16
- export function registerChatHandlers(h) { chatHandlers = h; }
17
-
18
- let instanceChatRelay = null;
19
- export function registerInstanceChatRelay(fn) { instanceChatRelay = fn; }
20
-
21
- // Надёжный HTTP-клиент для platform-api2.max.ru (поддерживает сертификат НУЦ Минцифры РФ)
22
- const maxCa = TRUSTED_CA;
23
-
24
- function maxRequest(urlPath, { method = 'GET', token = '', body = null, timeoutMs = 12000 } = {}) {
25
- return new Promise((resolve, reject) => {
26
- const u = new URL(urlPath, 'https://platform-api2.max.ru');
27
- const payload = body ? Buffer.from(typeof body === 'string' ? body : JSON.stringify(body)) : null;
28
- const req = https.request({
29
- hostname: u.hostname,
30
- port: u.port || 443,
31
- path: u.pathname + u.search,
32
- method,
33
- ca: maxCa,
34
- rejectUnauthorized: true,
35
- headers: {
36
- Authorization: token,
37
- Accept: 'application/json',
38
- ...(payload ? { 'Content-Type': 'application/json', 'Content-Length': payload.length } : {}),
39
- },
40
- timeout: timeoutMs,
41
- }, (res) => {
42
- let raw = '';
43
- res.setEncoding('utf8');
44
- res.on('data', (chunk) => { raw += chunk; });
45
- res.on('end', () => {
46
- let json = null;
47
- try { json = JSON.parse(raw); } catch { /* ignore */ }
48
- resolve({ ok: res.statusCode >= 200 && res.statusCode < 300, status: res.statusCode, json, text: raw });
49
- });
50
- });
51
- req.on('timeout', () => req.destroy(new Error('MAX API timeout')));
52
- req.on('error', reject);
53
- if (payload) req.write(payload);
54
- req.end();
55
- });
56
- }
57
-
58
- // Одноразовые токены входа через Telegram-бота (t.me/bot?start=LOGIN_xxx)
59
- const tgLoginChallenges = new Map(); // code -> { status: 'pending' | 'done', userId: null | number, createdAt }
60
-
61
- export function createTelegramLoginChallenge() {
62
- const now = Date.now();
63
- for (const [k, v] of tgLoginChallenges) if (now - v.createdAt > 10 * 60000) tgLoginChallenges.delete(k);
64
- const code = `LOGIN_${crypto.randomBytes(6).toString('hex').toUpperCase()}`;
65
- tgLoginChallenges.set(code, { status: 'pending', userId: null, createdAt: now });
66
- return code;
67
- }
68
-
69
- export function checkTelegramLoginChallenge(code) {
70
- const ch = tgLoginChallenges.get(String(code || '').trim());
71
- if (!ch) return { status: 'expired' };
72
- if (ch.status === 'done' && ch.userId) {
73
- tgLoginChallenges.delete(code);
74
- return { status: 'done', userId: ch.userId };
75
- }
76
- return { status: 'pending' };
77
- }
78
-
79
- function handleBotLogin(text, channel, chatId, senderName = '', senderUsername = '') {
80
- const m = String(text || '').toUpperCase().match(/LOGIN_[A-Z0-9]{12}/);
81
- if (!m) return null;
82
- const ch = tgLoginChallenges.get(m[0]);
83
- if (!ch || ch.status !== 'pending') return null;
84
- const cid = String(chatId);
85
- const col = channel === 'max' ? 'max_chat_id' : 'tg_chat_id';
86
- const dom = channel === 'max' ? 'max.neuroalpha.org' : 'tg.neuroalpha.org';
87
- const name = String(senderName || '').trim() || senderUsername || `${channel.toUpperCase()} ${cid}`;
88
- const email = senderUsername ? `${String(senderUsername).toLowerCase()}@${dom}` : `${channel}_${cid}@${dom}`;
89
-
90
- let user = db.prepare(`SELECT * FROM users WHERE ${col} = ? OR email = ?`).get(cid, email);
91
- if (!user) {
92
- const notifyCode = `NA-${crypto.randomBytes(3).toString('hex').toUpperCase()}`;
93
- const apiKey = generateApiKey();
94
- const rndPass = hashPassword(crypto.randomBytes(16).toString('hex'));
95
- const r = db.prepare(`INSERT INTO users (email, name, pass_hash, role, plan, plan_until, ${col}, notify_code, api_key, created_at)
96
- VALUES (?, ?, ?, 'user', 'free', 0, ?, ?, ?, ?)`).run(email, name, rndPass, cid, notifyCode, apiKey, Date.now());
97
- user = getUserById(Number(r.lastInsertRowid));
98
- } else {
99
- if (!user[col]) db.prepare(`UPDATE users SET ${col} = ? WHERE id = ?`).run(cid, user.id);
100
- if (!user.api_key) ensureApiKey(user.id);
101
- user = getUserById(user.id);
102
- }
103
- ch.status = 'done';
104
- ch.userId = user.id;
105
- return user;
106
- }
107
-
108
- export function getNotifyConfig() {
109
- const saved = kvGet('notifyConfig') || {};
110
- const tgToken = decryptSecret(saved.tgToken || '') || String(process.env.TELEGRAM_BOT_TOKEN || '').trim();
111
- const tgBotName = String(saved.tgBotName || process.env.TELEGRAM_BOT_NAME || 'neuroalpha_trade_bot').trim().replace(/^@/, '');
112
- const maxToken = decryptSecret(saved.maxToken || '') || String(process.env.MAX_BOT_TOKEN || '').trim();
113
- const maxBotName = String(saved.maxBotName || process.env.MAX_BOT_NAME || 'id6234049193_bot').trim().replace(/^@/, '');
114
- return { tgToken, tgBotName, maxToken, maxBotName, tgEnabled: Boolean(tgToken), maxEnabled: Boolean(maxToken) };
115
- }
116
-
117
- export function publicNotifyConfig() {
118
- const c = getNotifyConfig();
119
- return { tgBotName: c.tgBotName, maxBotName: c.maxBotName, hasTgToken: c.tgEnabled, hasMaxToken: c.maxEnabled };
120
- }
121
-
122
- export async function saveNotifyConfig(input = {}) {
123
- const prev = kvGet('notifyConfig') || {};
124
- const rawMaxToken = input.clearMax ? '' : (input.maxToken && String(input.maxToken).trim() ? String(input.maxToken).trim() : decryptSecret(prev.maxToken || ''));
125
- let maxBotName = String(input.maxBotName ?? prev.maxBotName ?? '').trim().replace(/^@/, '');
126
- if (rawMaxToken && !maxBotName) {
127
- try {
128
- const me = await maxRequest('/me', { token: rawMaxToken, timeoutMs: 8000 });
129
- if (me.ok && me.json?.username) maxBotName = String(me.json.username).trim().replace(/^@/, '');
130
- } catch { /* ignore */ }
131
- }
132
- const next = {
133
- tgBotName: String(input.tgBotName ?? prev.tgBotName ?? '').trim().replace(/^@/, ''),
134
- tgToken: input.clearTg ? '' : (input.tgToken && String(input.tgToken).trim() ? encryptSecret(input.tgToken) : (prev.tgToken || '')),
135
- maxBotName,
136
- maxToken: input.clearMax ? '' : (input.maxToken && String(input.maxToken).trim() ? encryptSecret(input.maxToken) : (prev.maxToken || '')),
137
- };
138
- kvSet('notifyConfig', next);
139
- return publicNotifyConfig();
140
- }
141
-
142
- // ---------- отправка сообщений в Telegram и MAX ----------
143
- export async function sendTelegram(chatId, text, buttons = null) {
144
- const { tgToken } = getNotifyConfig();
145
- if (!tgToken || !chatId) return false;
146
- const body = {
147
- chat_id: chatId,
148
- text,
149
- ...(buttons?.length ? { reply_markup: { inline_keyboard: [buttons.map((b) => ({ text: b.label, callback_data: b.data }))] } } : {}),
150
- };
151
- const r = await fetch(`https://api.telegram.org/bot${tgToken}/sendMessage`, {
152
- method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), signal: AbortSignal.timeout(12000),
153
- }).catch(() => null);
154
- return Boolean(r?.ok);
155
- }
156
-
157
- export async function sendMax(chatId, text, buttons = null) {
158
- const { maxToken } = getNotifyConfig();
159
- if (!maxToken || !chatId) return false;
160
- const attachments = buttons?.length ? [{
161
- type: 'inline_keyboard',
162
- payload: {
163
- buttons: [buttons.map((b) => ({ type: 'callback', text: b.label, payload: b.data }))],
164
- },
165
- }] : undefined;
166
- const r = await maxRequest(`/messages?chat_id=${encodeURIComponent(chatId)}`, {
167
- method: 'POST',
168
- token: maxToken,
169
- body: { text, ...(attachments ? { attachments } : {}) },
170
- timeoutMs: 12000,
171
- }).catch(() => null);
172
- return Boolean(r?.ok);
173
- }
174
-
175
- export async function notifyUser(userId, text, buttons = null) {
176
- // На машине клиента ботов нет: текст и кнопки уходят в центр, а центр пишет клиенту в его Telegram / MAX.
177
- // Нажатие кнопки центр возвращает машине командой внутри подписанной лицензии.
178
- if (IS_INSTANCE) {
179
- const termUrl = getIdentity().url;
180
- const relayButtons = (buttons || []).filter((b) => b.action && b.accountId).map((b) => ({ label: b.label, action: b.action, accountId: b.accountId, batchId: b.batchId || '' }));
181
- const hint = buttons?.length ? `\n\nПодтвердите кнопкой ниже или в терминале${termUrl ? `: ${termUrl}` : ' на вашем компьютере'}.` : '';
182
- return relayNotifyToCentral(`${text}${hint}`, relayButtons);
183
- }
184
- const u = getUserById(userId);
185
- if (!u) return { tg: false, max: false };
186
- const [tg, max] = await Promise.all([
187
- u.tg_chat_id ? sendTelegram(u.tg_chat_id, text, buttons) : false,
188
- u.max_chat_id ? sendMax(u.max_chat_id, text, buttons) : false,
189
- ]);
190
- return { tg, max };
191
- }
192
-
193
- export async function notifyBrokerBatch(userId, acc, batch) {
194
- if (!batch?.orders?.length) return;
195
- const actionLabel = (a) => (a === 'buy' ? 'Купить' : a === 'cover' ? 'Закрыть шорт (купить)' : a === 'short' ? 'Шорт (продать)' : 'Продать');
196
- const lines = batch.orders.map((o) => {
197
- const why = o.reason ? `\n ↳ ${o.reason}` : '';
198
- return `• ${actionLabel(o.action)} ${o.symbol}: ${o.lots} л. (${o.shares} шт.) по ${o.limitPrice} ₽ ≈ ${round(o.amountRub, 0)} ₽${why}`;
199
- });
200
- const isExecuted = batch.status === 'executed' || batch.status === 'partial';
201
- const header = isExecuted
202
- ? `Выставлены лимитные заявки (свеча ${batch.bar || '—'}):`
203
- : `Сформирован пакет лимитных заявок (свеча ${batch.bar || '—'}):`;
204
- const text = `НейроАльфа · Счёт «${acc.name}»\n${header}\n${lines.join('\n')}`;
205
- const buttons = acc.mode === 'confirm' && !isExecuted ? [
206
- { label: `✓ Исполнить (${batch.orders.length})`, data: `exec:${userId}:${acc.id}`, action: 'exec', accountId: acc.id, batchId: batch.id || '' },
207
- { label: '✕ Отклонить', data: `rej:${userId}:${acc.id}`, action: 'rej', accountId: acc.id, batchId: batch.id || '' },
208
- ] : null;
209
- await notifyUser(userId, text, buttons);
210
- }
211
-
212
- // ---------- обработка команд привязки и кнопок подтверждения в Telegram и MAX ----------
213
- function tryLinkByCode(text, channel, chatId) {
214
- const m = String(text || '').toUpperCase().match(/NA-[A-Z0-9]{6}/);
215
- if (!m) return null;
216
- const u = db.prepare('SELECT * FROM users WHERE UPPER(notify_code) = ?').get(m[0]);
217
- if (!u) return null;
218
- const col = channel === 'tg' ? 'tg_chat_id' : 'max_chat_id';
219
- db.prepare(`UPDATE users SET ${col} = ? WHERE id = ?`).run(String(chatId), u.id);
220
- return u;
221
- }
222
-
223
- // Очистка Markdown-разметки под приятный для чтения текст в мессенджере и разбивка длинных ответов на части
224
- function formatMessengerChunks(raw = '') {
225
- const cleaned = String(raw || '')
226
- .replace(/\*\*([^*]+)\*\*/g, '$1')
227
- .replace(/^#{1,4}\s+/gm, '▸ ')
228
- .replace(/`([^`]+)`/g, '$1')
229
- .trim();
230
- if (!cleaned) return ['Готово.'];
231
- const MAX_LEN = 3600;
232
- if (cleaned.length <= MAX_LEN) return [cleaned];
233
- const chunks = [];
234
- let rest = cleaned;
235
- while (rest.length > MAX_LEN) {
236
- let cut = rest.lastIndexOf('\n\n', MAX_LEN);
237
- if (cut < 800) cut = rest.lastIndexOf('\n', MAX_LEN);
238
- if (cut < 800) cut = MAX_LEN;
239
- chunks.push(rest.slice(0, cut).trim());
240
- rest = rest.slice(cut).trim();
241
- }
242
- if (rest) chunks.push(rest);
243
- return chunks;
244
- }
245
-
246
- async function sendTelegramTyping(chatId) {
247
- const { tgToken } = getNotifyConfig();
248
- if (!tgToken || !chatId) return;
249
- await fetch(`https://api.telegram.org/bot${tgToken}/sendChatAction`, {
250
- method: 'POST',
251
- headers: { 'Content-Type': 'application/json' },
252
- body: JSON.stringify({ chat_id: chatId, action: 'typing' }),
253
- signal: AbortSignal.timeout(5000),
254
- }).catch(() => null);
255
- }
256
-
257
- // Обработка текстового вопроса к ИИ-Стратегу из Telegram или MAX
258
- async function handleMessengerChat(text, channel, chatId, sendFn) {
259
- const col = channel === 'max' ? 'max_chat_id' : 'tg_chat_id';
260
- const u = db.prepare(`SELECT * FROM users WHERE ${col} = ?`).get(String(chatId));
261
- if (!u) {
262
- await sendFn('◈ Чат ещё не привязан к аккаунту НейроАльфа.\n\nОтправьте сюда ваш код привязки (вида NA-XXXXXX) из раздела «Подписка и уведомления» в терминале — после этого вы сможете задавать вопросы ИИ-Стратегу прямо здесь.');
263
- return;
264
- }
265
- if (/^(\/start|\/help|помощь|справка)$/i.test(text)) {
266
- await sendFn(
267
- `◈ НейроАльфа · ИИ-Стратег на связи (${u.name || u.email}).\n\n` +
268
- 'Напишите любой вопрос обычным текстом (например: «Что по SBER?», «Разбери мой портфель», «Какие сделки были вчера?») или используйте быстрые команды:\n' +
269
- '• /forecast (или «Прогноз») — свежий прогноз и сигналы\n' +
270
- '• /portfolio (или «Портфель») — состояние счетов и маржи\n' +
271
- '• /today (или «Сегодня») — сделки и результат за день\n' +
272
- '• /news (или «Новости») — главные новости рынка\n' +
273
- '• /new <вопрос> — начать новую тему в архиве',
274
- );
275
- return;
276
- }
277
- const plan = effectivePlan(u);
278
- // Если у пользователя нет локальных брокерских счетов на центральном сервере, но есть выделенная машина (cloud / desktop),
279
- // пересылаем вопрос на его машину, где хранятся его счета и прогнозы!
280
- const hasLocalAccounts = Boolean(
281
- db.prepare('SELECT 1 FROM broker_accounts WHERE user_id = ? LIMIT 1').get(u.id),
282
- );
283
- if (!hasLocalAccounts && u.role !== 'admin' && instanceChatRelay) {
284
- const relayed = instanceChatRelay(u.id, text, channel);
285
- if (relayed) {
286
- await sendFn(relayed.online
287
- ? '⏳ Запрос передан Стратегу на вашу машину — ответ придёт сюда через несколько секунд…'
288
- : '⏳ Запрос поставлен в очередь вашей машины (сейчас она не на связи — Стратег ответит при подключении).');
289
- return;
290
- }
291
- }
292
- if (!chatHandlers.ask) return;
293
- if (channel === 'tg') sendTelegramTyping(chatId);
294
- try {
295
- const res = await runWithUser(u, async () => {
296
- if (countUserChatToday() >= (plan.maxChatPerDay || 50)) {
297
- throw new Error(`Достигнут дневной лимит сообщений Стратегу по тарифу «${plan.name}» (${plan.maxChatPerDay} в сутки).`);
298
- }
299
- return await chatHandlers.ask(text, { channel });
300
- });
301
- const chunks = formatMessengerChunks(res?.text || '');
302
- const buttons = res?.waiting ? [
303
- { label: '✓ Применить', data: `aiact:approve:${u.id}:${res.waiting.msgId}:${res.waiting.callId}` },
304
- { label: '✕ Отклонить', data: `aiact:reject:${u.id}:${res.waiting.msgId}:${res.waiting.callId}` },
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
+ }