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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "neuroalpha",
3
- "version": "0.7.3",
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) и пароль удалённого доступа (VPS) ----------
4232
+ // ---------- Отдельный чистый раздел: Уведомления (единые боты платформы Telegram / MAX) и пароль VPS ----------
4233
4233
  async function renderAccessPane() {
4234
4234
  const box = $('#access-pane');
4235
4235
  if (!box) return;
4236
- const me = await api('/api/auth/me').catch(() => state.config || {});
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:10px">
4245
- <div style="font-size:15.5px;font-weight:700">💬 Уведомления и диалог со Стратегом в Telegram и MAX</div>
4246
- <button type="button" id="btn-notify-test">Отправить тест уведомления</button>
4247
- </div>
4248
- <p class="muted small" style="margin:0 0 14px">
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
- <div><button type="submit" class="primary">Сохранить настройки уведомлений</button></div>
4262
- </form>
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/config', {
4290
- usePortalRelay: f.usePortalRelay.checked,
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 (err) { toast(err.message, 'error'); }
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();
@@ -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, saveNotifyConfig, notifyUser } from '../notify/index.js';
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.post('/notify/config', wrap((req, res) => {
173
- res.json({ ok: true, notify: saveNotifyConfig(req.body || {}) });
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('Уведомление не доставлено: привяжите Telegram/MAX по коду в личном кабинете на портале либо укажите токен личного бота ниже.');
183
+ throw new Error('Сначала нажмите «Подключить Telegram» или «Подключить MAX» и нажмите Start в окне бота.');
180
184
  }
181
185
  res.json({ ok: true, ...sent });
182
186
  }));
@@ -1,12 +1,8 @@
1
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';
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
- 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
- };
15
+ return { mode: 'portal_relay' };
71
16
  }
72
17
 
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 };
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(`https://api.telegram.org/bot${tgToken}/sendMessage`, {
94
- method: 'POST',
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
- }).catch(() => null);
99
- return Boolean(r?.ok);
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 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))}`, {
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
- token: maxToken,
121
- body: payload,
122
- }).catch(() => null);
123
- return Boolean(r?.ok);
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 sendViaPortalRelay(text, buttons = null) {
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, ...(buttons ? { buttons } : {}) }),
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
- 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;
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 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} ₽`;
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 header = acc.mode === 'auto'
174
- ? `⚡ Автоисполнение заявок · счёт «${acc.name}»`
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
- const hasDirect = Boolean((c.tgToken && c.tgChatId) || (c.maxToken && c.maxChatId));
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, 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);
173
+ const t = setTimeout(pollPortalRelay, 10000);
299
174
  t.unref?.();
300
175
  };
301
176
 
302
177
  pollPortalRelay();
303
- pollDirectTg();
304
178
  }