neuroalpha 0.7.3 → 0.7.4
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/package.json +1 -1
- package/public/app.js +67 -35
- package/server/api/routes.js +9 -5
- package/server/notify/index.js +61 -187
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "neuroalpha",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.4",
|
|
4
4
|
"description": "НейроАльфа — автономный торговый терминал для рынка акций Мосбиржи: эмбеддинги акций, самообучение правил, коннекторы Т-Банк / БКС / Финам / Алор, активация токеном ЛК и автоустановка локальной ИИ-модели Стратега",
|
|
5
5
|
"author": "Roman A. Perepelkin",
|
|
6
6
|
"license": "UNLICENSED",
|
package/public/app.js
CHANGED
|
@@ -4229,37 +4229,77 @@ async function renderCloudPane() {
|
|
|
4229
4229
|
});
|
|
4230
4230
|
}
|
|
4231
4231
|
|
|
4232
|
-
// ---------- Отдельный чистый раздел: Уведомления (Telegram / MAX) и пароль
|
|
4232
|
+
// ---------- Отдельный чистый раздел: Уведомления (единые боты платформы Telegram / MAX) и пароль VPS ----------
|
|
4233
4233
|
async function renderAccessPane() {
|
|
4234
4234
|
const box = $('#access-pane');
|
|
4235
4235
|
if (!box) return;
|
|
4236
|
-
const me = await
|
|
4236
|
+
const [me, botSt] = await Promise.all([
|
|
4237
|
+
api('/api/auth/me').catch(() => state.config || {}),
|
|
4238
|
+
api('/api/notify/status').catch((e) => ({ ok: false, error: e.message })),
|
|
4239
|
+
]);
|
|
4237
4240
|
state.config = { ...(state.config || {}), ...me };
|
|
4238
4241
|
const lic = me.license || me.user?.license || {};
|
|
4239
|
-
const nt = me.notify || {};
|
|
4240
4242
|
const portalUrl = lic.portalUrl || 'https://neuroalpha.odant.org';
|
|
4241
4243
|
|
|
4244
|
+
let botsContentHtml = '';
|
|
4245
|
+
if (!lic.hasCabinetToken) {
|
|
4246
|
+
botsContentHtml = `<div class="muted small" style="padding:12px 14px;border:1px solid var(--line);border-radius:10px;background:var(--bg)">
|
|
4247
|
+
Сначала привяжите токен личного кабинета в разделе <b>«Лицензия»</b> — после этого здесь сразу появятся кнопки привязки Telegram и MAX в 1 клик.
|
|
4248
|
+
<div style="margin-top:10px"><button type="button" class="primary" data-goto-license>Перейти в раздел «Лицензия» →</button></div>
|
|
4249
|
+
</div>`;
|
|
4250
|
+
} else if (botSt?.error) {
|
|
4251
|
+
botsContentHtml = `<div class="neg small">Не удалось запросить статус ботов с портала (${esc(portalUrl)}): ${esc(botSt.error)}</div>`;
|
|
4252
|
+
} else {
|
|
4253
|
+
const anyBot = Boolean(botSt?.hasTgBot || botSt?.hasMaxBot);
|
|
4254
|
+
botsContentHtml = `
|
|
4255
|
+
${!anyBot
|
|
4256
|
+
? `<div class="small" style="padding:10px 14px;border-radius:10px;border:1px solid var(--yellow);color:var(--yellow);margin-bottom:12px">
|
|
4257
|
+
На сервере портала (<code>${esc(portalUrl.replace(/^https?:\/\//, ''))}</code>) в <b>Админ-панели → Облачный ИИ и Боты</b> ещё не вставлены токены ботов платформы.
|
|
4258
|
+
</div>`
|
|
4259
|
+
: `<p class="muted small" style="margin:0 0 12px">
|
|
4260
|
+
Нажмите кнопку подключения — откроется официальный бот платформы. Нажмите в нём <b>Запустить (Start)</b>, и ваш личный чат привяжется автоматически. Все уведомления и ответы Стратега приходят строго приватно только вам.
|
|
4261
|
+
</p>`}
|
|
4262
|
+
|
|
4263
|
+
<div style="display:grid;gap:10px">
|
|
4264
|
+
<div style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap;padding:12px 14px;border:1px solid var(--line);border-radius:10px;background:var(--bg)">
|
|
4265
|
+
<div>
|
|
4266
|
+
<b>Telegram</b> ${botSt?.tgBotName ? `<span class="muted small">(@${esc(botSt.tgBotName)})</span>` : ''}
|
|
4267
|
+
<div style="margin-top:3px">${botSt?.tgConnected ? '<span class="tag ok">✓ Личный чат привязан</span>' : '<span class="tag">Не привязан</span>'}</div>
|
|
4268
|
+
</div>
|
|
4269
|
+
<div class="row-gap" style="flex-wrap:wrap">
|
|
4270
|
+
${botSt?.tgLink
|
|
4271
|
+
? `<a class="btn ${botSt.tgConnected ? '' : 'primary'}" href="${esc(botSt.tgLink)}" target="_blank" rel="noopener">${botSt.tgConnected ? 'Перепривязать Telegram ↗' : 'Подключить Telegram в 1 клик ↗'}</a>`
|
|
4272
|
+
: (!botSt?.hasTgBot ? '<span class="muted small">Не задан в /admin портала</span>' : `<span class="muted small">Код привязки: <code>${esc(botSt.notifyCode || '')}</code></span>`)}
|
|
4273
|
+
${botSt?.tgConnected ? '<button type="button" class="btn-unlink-soft" style="height:32px;padding:0 12px" data-unbind-bot="tg">Отвязать</button>' : ''}
|
|
4274
|
+
</div>
|
|
4275
|
+
</div>
|
|
4276
|
+
|
|
4277
|
+
<div style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap;padding:12px 14px;border:1px solid var(--line);border-radius:10px;background:var(--bg)">
|
|
4278
|
+
<div>
|
|
4279
|
+
<b>Мессенджер MAX</b> ${botSt?.maxBotName ? `<span class="muted small">(@${esc(botSt.maxBotName)})</span>` : ''}
|
|
4280
|
+
<div style="margin-top:3px">${botSt?.maxConnected ? '<span class="tag ok">✓ Личный чат привязан</span>' : '<span class="tag">Не привязан</span>'}</div>
|
|
4281
|
+
</div>
|
|
4282
|
+
<div class="row-gap" style="flex-wrap:wrap">
|
|
4283
|
+
${botSt?.maxLink
|
|
4284
|
+
? `<a class="btn ${botSt.maxConnected ? '' : 'primary'}" href="${esc(botSt.maxLink)}" target="_blank" rel="noopener">${botSt.maxConnected ? 'Перепривязать MAX ↗' : 'Подключить MAX в 1 клик ↗'}</a>`
|
|
4285
|
+
: (!botSt?.hasMaxBot ? '<span class="muted small">Не задан в /admin портала</span>' : `<span class="muted small">Код привязки: <code>${esc(botSt.notifyCode || '')}</code></span>`)}
|
|
4286
|
+
${botSt?.maxConnected ? '<button type="button" class="btn-unlink-soft" style="height:32px;padding:0 12px" data-unbind-bot="max">Отвязать</button>' : ''}
|
|
4287
|
+
</div>
|
|
4288
|
+
</div>
|
|
4289
|
+
</div>
|
|
4290
|
+
`;
|
|
4291
|
+
}
|
|
4292
|
+
|
|
4242
4293
|
box.innerHTML = `
|
|
4243
4294
|
<div class="lic-card">
|
|
4244
|
-
<div style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:
|
|
4245
|
-
<div style="font-size:15.5px;font-weight:700">💬 Уведомления и
|
|
4246
|
-
<
|
|
4247
|
-
|
|
4248
|
-
|
|
4249
|
-
По умолчанию уведомления и кнопки подтверждения заявок работают через официальных ботов портала (привяжите ваш мессенджер во вкладке «Личный кабинет → Telegram / MAX» на <a href="${esc(portalUrl)}/#cabinet" target="_blank" rel="noopener">${esc(portalUrl.replace(/^https?:\/\//, ''))}</a>). Ниже при желании можно подключить собственного личного бота напрямую.
|
|
4250
|
-
</p>
|
|
4251
|
-
<form id="direct-notify-form" style="display:grid;gap:10px;padding-top:12px;border-top:1px solid var(--line)">
|
|
4252
|
-
<label class="row-gap small"><input type="checkbox" name="usePortalRelay" ${nt.usePortalRelay !== false ? 'checked' : ''}> Использовать ботов портала, если прямой токен бота не заполнен</label>
|
|
4253
|
-
<div style="display:grid;grid-template-columns:1fr 220px;gap:10px">
|
|
4254
|
-
<input name="tgToken" type="text" class="secret-input" autocomplete="off" placeholder="${nt.hasDirectTg ? 'Токен личного Telegram-бота сохранён' : 'Токен личного Telegram-бота (@BotFather)'}">
|
|
4255
|
-
<input name="tgChatId" value="${esc(nt.tgChatId || '')}" placeholder="Telegram Chat ID">
|
|
4256
|
-
</div>
|
|
4257
|
-
<div style="display:grid;grid-template-columns:1fr 220px;gap:10px">
|
|
4258
|
-
<input name="maxToken" type="text" class="secret-input" autocomplete="off" placeholder="${nt.hasDirectMax ? 'Токен личного MAX-бота сохранён' : 'Токен личного MAX-бота'}">
|
|
4259
|
-
<input name="maxChatId" value="${esc(nt.maxChatId || '')}" placeholder="MAX Chat ID">
|
|
4295
|
+
<div style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:12px">
|
|
4296
|
+
<div style="font-size:15.5px;font-weight:700">💬 Уведомления и подтверждение заявок в Telegram и MAX</div>
|
|
4297
|
+
<div class="row-gap">
|
|
4298
|
+
<button type="button" id="btn-bots-refresh">↻ Обновить статус</button>
|
|
4299
|
+
<button type="button" id="btn-notify-test" ${!(botSt?.tgConnected || botSt?.maxConnected) ? 'disabled' : ''}>Отправить тест</button>
|
|
4260
4300
|
</div>
|
|
4261
|
-
|
|
4262
|
-
|
|
4301
|
+
</div>
|
|
4302
|
+
${botsContentHtml}
|
|
4263
4303
|
</div>
|
|
4264
4304
|
|
|
4265
4305
|
<div class="lic-card">
|
|
@@ -4277,26 +4317,18 @@ async function renderAccessPane() {
|
|
|
4277
4317
|
</div>
|
|
4278
4318
|
`;
|
|
4279
4319
|
|
|
4320
|
+
box.querySelector('#btn-bots-refresh')?.addEventListener('click', () => renderAccessPane());
|
|
4280
4321
|
box.querySelector('#btn-notify-test')?.addEventListener('click', async () => {
|
|
4281
4322
|
try { await post('/api/notify/test'); toast('✓ Тестовое уведомление отправлено'); }
|
|
4282
4323
|
catch (e) { toast(e.message, 'error'); }
|
|
4283
4324
|
});
|
|
4284
|
-
|
|
4285
|
-
box.querySelector('#direct-notify-form')?.addEventListener('submit', async (e) => {
|
|
4286
|
-
e.preventDefault();
|
|
4287
|
-
const f = e.target.elements;
|
|
4325
|
+
box.querySelectorAll('[data-unbind-bot]').forEach((b) => b.addEventListener('click', async () => {
|
|
4288
4326
|
try {
|
|
4289
|
-
await post('/api/notify/
|
|
4290
|
-
|
|
4291
|
-
tgToken: f.tgToken.value,
|
|
4292
|
-
tgChatId: f.tgChatId.value,
|
|
4293
|
-
maxToken: f.maxToken.value,
|
|
4294
|
-
maxChatId: f.maxChatId.value,
|
|
4295
|
-
});
|
|
4296
|
-
toast('✓ Настройки уведомлений сохранены');
|
|
4327
|
+
await post('/api/notify/unbind', { channel: b.dataset.unbindBot });
|
|
4328
|
+
toast('Мессенджер отвязан');
|
|
4297
4329
|
renderAccessPane();
|
|
4298
|
-
} catch (
|
|
4299
|
-
});
|
|
4330
|
+
} catch (e) { toast(e.message, 'error'); }
|
|
4331
|
+
}));
|
|
4300
4332
|
|
|
4301
4333
|
box.querySelector('#form-local-pass')?.addEventListener('submit', async (e) => {
|
|
4302
4334
|
e.preventDefault();
|
package/server/api/routes.js
CHANGED
|
@@ -20,7 +20,7 @@ import {
|
|
|
20
20
|
isLoopbackRequest, getTerminalLicenseState, activateTerminalByToken, detachTerminalLicense,
|
|
21
21
|
loginRemoteTerminal, setTerminalLocalPassword, tradingGate,
|
|
22
22
|
} from '../auth/index.js';
|
|
23
|
-
import { publicNotifyConfig,
|
|
23
|
+
import { publicNotifyConfig, fetchPortalMessengerStatus, unbindPortalMessenger, notifyUser } from '../notify/index.js';
|
|
24
24
|
import { PARAMS } from '../params-meta.js';
|
|
25
25
|
import { getSettings, updateSettings, resetSettings, revertSettingsChange } from '../settings.js';
|
|
26
26
|
import { startModelResearch, cancelModelResearch, startStabilityCheck, cancelStabilityCheck } from '../model/research.js';
|
|
@@ -168,15 +168,19 @@ export function createRouter() {
|
|
|
168
168
|
res.json({ ok: true });
|
|
169
169
|
});
|
|
170
170
|
|
|
171
|
-
// ---------- Уведомления
|
|
172
|
-
r.
|
|
173
|
-
res.json(
|
|
171
|
+
// ---------- Уведомления через единых ботов платформы (Telegram и MAX) ----------
|
|
172
|
+
r.get('/notify/status', wrap(async (req, res) => {
|
|
173
|
+
res.json(await fetchPortalMessengerStatus());
|
|
174
|
+
}));
|
|
175
|
+
|
|
176
|
+
r.post('/notify/unbind', wrap(async (req, res) => {
|
|
177
|
+
res.json(await unbindPortalMessenger(req.body?.channel));
|
|
174
178
|
}));
|
|
175
179
|
|
|
176
180
|
r.post('/notify/test', wrap(async (req, res) => {
|
|
177
181
|
const sent = await notifyUser(1, '◈ НейроАльфа Терминал: тестовое уведомление успешно доставлено.');
|
|
178
182
|
if (!sent.tg && !sent.max) {
|
|
179
|
-
throw new Error('
|
|
183
|
+
throw new Error('Сначала нажмите «Подключить Telegram» или «Подключить MAX» и нажмите Start в окне бота.');
|
|
180
184
|
}
|
|
181
185
|
res.json({ ok: true, ...sent });
|
|
182
186
|
}));
|
package/server/notify/index.js
CHANGED
|
@@ -1,12 +1,8 @@
|
|
|
1
1
|
// Copyright (c) 2026 Roman A. Perepelkin. All rights reserved. Proprietary software.
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
import
|
|
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';
|
|
2
|
+
// Клиент единого шлюза уведомлений Telegram и MAX через портал платформы:
|
|
3
|
+
// • никаких ручных токенов ботов и Chat ID в терминале — привязка выполняется в 1 клик по ссылке портала;
|
|
4
|
+
// • сообщения и кнопки подтверждения заявок доставляются строго в персональный chat_id владельца токена ЛК (na_tok_...).
|
|
5
|
+
import { getUserById, runWithUser, getStoredCabinetCredentials, tradingGate } from '../auth/index.js';
|
|
10
6
|
import { round } from '../util.js';
|
|
11
7
|
|
|
12
8
|
let actionHandlers = { execute: null, reject: null };
|
|
@@ -15,117 +11,51 @@ export function registerBrokerHandlers(h) { actionHandlers = h; }
|
|
|
15
11
|
let chatHandlers = { ask: null };
|
|
16
12
|
export function registerChatHandlers(h) { chatHandlers = h; }
|
|
17
13
|
|
|
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
14
|
export function publicNotifyConfig() {
|
|
63
|
-
|
|
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
|
-
};
|
|
15
|
+
return { mode: 'portal_relay' };
|
|
71
16
|
}
|
|
72
17
|
|
|
73
|
-
export function
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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 };
|
|
18
|
+
export async function fetchPortalMessengerStatus() {
|
|
19
|
+
const { token, portalUrl } = getStoredCabinetCredentials();
|
|
20
|
+
if (!token || !portalUrl) {
|
|
21
|
+
return {
|
|
22
|
+
ok: false,
|
|
23
|
+
linkedToPortal: false,
|
|
24
|
+
hasTgBot: false,
|
|
25
|
+
tgConnected: false,
|
|
26
|
+
tgLink: '',
|
|
27
|
+
hasMaxBot: false,
|
|
28
|
+
maxConnected: false,
|
|
29
|
+
maxLink: '',
|
|
30
|
+
};
|
|
92
31
|
}
|
|
93
|
-
const r = await fetch(
|
|
94
|
-
|
|
95
|
-
headers: { 'Content-Type': 'application/json' },
|
|
96
|
-
body: JSON.stringify(body),
|
|
32
|
+
const r = await fetch(`${portalUrl}/api/relay/status`, {
|
|
33
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
97
34
|
signal: AbortSignal.timeout(12000),
|
|
98
|
-
})
|
|
99
|
-
|
|
35
|
+
});
|
|
36
|
+
const j = await r.json().catch(() => ({}));
|
|
37
|
+
if (!r.ok) throw new Error(j.error || `Портал вернул HTTP ${r.status}`);
|
|
38
|
+
return { ...j, linkedToPortal: true, portalUrl };
|
|
100
39
|
}
|
|
101
40
|
|
|
102
|
-
async function
|
|
103
|
-
const {
|
|
104
|
-
if (!
|
|
105
|
-
const
|
|
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))}`, {
|
|
41
|
+
export async function unbindPortalMessenger(channel) {
|
|
42
|
+
const { token, portalUrl } = getStoredCabinetCredentials();
|
|
43
|
+
if (!token || !portalUrl) throw new Error('Сначала привяжите токен личного кабинета в разделе «Лицензия»');
|
|
44
|
+
const r = await fetch(`${portalUrl}/api/relay/unbind`, {
|
|
119
45
|
method: 'POST',
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
46
|
+
headers: {
|
|
47
|
+
'Content-Type': 'application/json',
|
|
48
|
+
Authorization: `Bearer ${token}`,
|
|
49
|
+
},
|
|
50
|
+
body: JSON.stringify({ channel: channel === 'max' ? 'max' : 'tg' }),
|
|
51
|
+
signal: AbortSignal.timeout(12000),
|
|
52
|
+
});
|
|
53
|
+
const j = await r.json().catch(() => ({}));
|
|
54
|
+
if (!r.ok) throw new Error(j.error || `HTTP ${r.status}`);
|
|
55
|
+
return { ...j, linkedToPortal: true, portalUrl };
|
|
124
56
|
}
|
|
125
57
|
|
|
126
|
-
async function
|
|
127
|
-
const { usePortalRelay } = getNotifyConfig();
|
|
128
|
-
if (!usePortalRelay) return { tg: false, max: false };
|
|
58
|
+
export async function notifyUser(_userId, text, relayButtons = null) {
|
|
129
59
|
const { token, portalUrl } = getStoredCabinetCredentials();
|
|
130
60
|
if (!token || !portalUrl) return { tg: false, max: false };
|
|
131
61
|
try {
|
|
@@ -135,7 +65,7 @@ async function sendViaPortalRelay(text, buttons = null) {
|
|
|
135
65
|
'Content-Type': 'application/json',
|
|
136
66
|
Authorization: `Bearer ${token}`,
|
|
137
67
|
},
|
|
138
|
-
body: JSON.stringify({ text, ...(
|
|
68
|
+
body: JSON.stringify({ text, ...(relayButtons ? { buttons: relayButtons } : {}) }),
|
|
139
69
|
signal: AbortSignal.timeout(12000),
|
|
140
70
|
});
|
|
141
71
|
const j = await r.json().catch(() => ({}));
|
|
@@ -145,45 +75,31 @@ async function sendViaPortalRelay(text, buttons = null) {
|
|
|
145
75
|
}
|
|
146
76
|
}
|
|
147
77
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
const
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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;
|
|
78
|
+
// Вызов из brokers/index.js: notifyBrokerBatch(currentUserId(), acc, batch)
|
|
79
|
+
export async function notifyBrokerBatch(arg1, arg2, arg3) {
|
|
80
|
+
const acc = arg3 !== undefined ? arg2 : arg1;
|
|
81
|
+
const batch = arg3 !== undefined ? arg3 : arg2;
|
|
82
|
+
if (!acc || !batch?.orders?.length) return;
|
|
83
|
+
const actionLabel = (a) => (a === 'buy' ? 'ПОКУПКА' : a === 'cover' ? 'ЗАКРЫТИЕ ШОРТА' : a === 'short' ? 'ШОРТ' : 'ПРОДАЖА');
|
|
169
84
|
const lines = batch.orders.map((o, idx) => {
|
|
170
|
-
const
|
|
171
|
-
return `${idx + 1}. ${
|
|
85
|
+
const why = o.reason ? `\n ↳ ${o.reason}` : '';
|
|
86
|
+
return `${idx + 1}. ${actionLabel(o.action)} ${o.symbol}: ${o.lots} л. (${o.shares} шт.) по ${o.limitPrice} ₽ ≈ ${round(o.amountRub, 0).toLocaleString('ru-RU')} ₽${why}`;
|
|
172
87
|
});
|
|
173
|
-
const
|
|
174
|
-
|
|
88
|
+
const isExecuted = batch.status === 'executed' || batch.status === 'partial';
|
|
89
|
+
const header = isExecuted
|
|
90
|
+
? `⚡ Выставлены заявки · счёт «${acc.name}»`
|
|
175
91
|
: `📋 Подтвердите пакет заявок · счёт «${acc.name}»`;
|
|
176
92
|
const text = `${header}\n\n${lines.join('\n')}`;
|
|
177
|
-
const buttons = acc.mode === 'confirm'
|
|
93
|
+
const buttons = acc.mode === 'confirm' && !isExecuted
|
|
178
94
|
? [
|
|
179
|
-
{ action: 'exec', accountId: acc.id, batchId: batch.id || '', text:
|
|
95
|
+
{ action: 'exec', accountId: acc.id, batchId: batch.id || '', text: `✓ Исполнить (${batch.orders.length})` },
|
|
180
96
|
{ action: 'rej', accountId: acc.id, batchId: batch.id || '', text: '✕ Отклонить' },
|
|
181
97
|
]
|
|
182
98
|
: null;
|
|
183
99
|
await notifyUser(1, text, buttons).catch(() => {});
|
|
184
100
|
}
|
|
185
101
|
|
|
186
|
-
// ----------
|
|
102
|
+
// ---------- Опрос входящих команд пользователя с портала ----------
|
|
187
103
|
const executedRelayIds = new Set();
|
|
188
104
|
async function processIncomingCommand(cmd) {
|
|
189
105
|
if (!cmd) return;
|
|
@@ -197,6 +113,10 @@ async function processIncomingCommand(cmd) {
|
|
|
197
113
|
}
|
|
198
114
|
await runWithUser(getUserById(1), async () => {
|
|
199
115
|
if (cmd.type === 'batch') {
|
|
116
|
+
if (!tradingGate().allowNew) {
|
|
117
|
+
await notifyUser(1, `⛔ ${tradingGate().reason}`);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
200
120
|
if (cmd.action === 'exec' && actionHandlers.execute) {
|
|
201
121
|
try {
|
|
202
122
|
const st = await actionHandlers.execute(cmd.accountId);
|
|
@@ -210,7 +130,7 @@ async function processIncomingCommand(cmd) {
|
|
|
210
130
|
} else if (cmd.action === 'rej' && actionHandlers.reject) {
|
|
211
131
|
try {
|
|
212
132
|
actionHandlers.reject(cmd.accountId);
|
|
213
|
-
await notifyUser(1,
|
|
133
|
+
await notifyUser(1, '✕ Пакет заявок по счёту отклонён.');
|
|
214
134
|
} catch (e) {
|
|
215
135
|
await notifyUser(1, `⚠ ${e.message}`);
|
|
216
136
|
}
|
|
@@ -225,18 +145,13 @@ async function processIncomingCommand(cmd) {
|
|
|
225
145
|
}
|
|
226
146
|
|
|
227
147
|
let pollingStarted = false;
|
|
228
|
-
let directTgOffset = 0;
|
|
229
|
-
|
|
230
148
|
export function startNotifyPolling() {
|
|
231
149
|
if (pollingStarted) return;
|
|
232
150
|
pollingStarted = true;
|
|
233
151
|
|
|
234
|
-
// 1) Опрос очереди ответных команд через портал (только если включён релей и есть токен ЛК)
|
|
235
152
|
const pollPortalRelay = async () => {
|
|
236
|
-
const c = getNotifyConfig();
|
|
237
153
|
const { token, portalUrl } = getStoredCabinetCredentials();
|
|
238
|
-
|
|
239
|
-
if (c.usePortalRelay && !hasDirect && token && portalUrl) {
|
|
154
|
+
if (token && portalUrl && tradingGate().allowNew) {
|
|
240
155
|
try {
|
|
241
156
|
const r = await fetch(`${portalUrl}/api/relay/poll`, {
|
|
242
157
|
method: 'POST',
|
|
@@ -255,50 +170,9 @@ export function startNotifyPolling() {
|
|
|
255
170
|
}
|
|
256
171
|
} catch { /* silent */ }
|
|
257
172
|
}
|
|
258
|
-
const t = setTimeout(pollPortalRelay,
|
|
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);
|
|
173
|
+
const t = setTimeout(pollPortalRelay, 10000);
|
|
299
174
|
t.unref?.();
|
|
300
175
|
};
|
|
301
176
|
|
|
302
177
|
pollPortalRelay();
|
|
303
|
-
pollDirectTg();
|
|
304
178
|
}
|