neuroalpha 0.7.2 → 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.2",
3
+ "version": "0.7.4",
4
4
  "description": "НейроАльфа — автономный торговый терминал для рынка акций Мосбиржи: эмбеддинги акций, самообучение правил, коннекторы Т-Банк / БКС / Финам / Алор, активация токеном ЛК и автоустановка локальной ИИ-модели Стратега",
5
5
  "author": "Roman A. Perepelkin",
6
6
  "license": "UNLICENSED",
package/public/app.js CHANGED
@@ -270,6 +270,8 @@ function showMainSec(sec) {
270
270
  loadNews();
271
271
  } else if (sec === 'cloud') {
272
272
  renderCloudPane().catch(() => {});
273
+ } else if (sec === 'access') {
274
+ renderAccessPane().catch(() => {});
273
275
  }
274
276
  }
275
277
  const switchTab = (t) => showMainSec(t);
@@ -4047,205 +4049,286 @@ async function renderCloudPane() {
4047
4049
  state.config = { ...(state.config || {}), ...me };
4048
4050
  const u = me.user;
4049
4051
  const lic = me.license || u?.license || {};
4050
- const nt = me.notify || {};
4051
4052
  updateTabBadges();
4052
4053
 
4053
4054
  const portalUrl = lic.portalUrl || 'https://neuroalpha.odant.org';
4055
+ const portalHost = portalUrl.replace(/^https?:\/\//, '');
4054
4056
  const slots = Array.isArray(lic.slots) ? lic.slots : [];
4057
+ const mainSlot = slots[0] || null;
4058
+ const allGrants = (mainSlot?.grants || []).filter((g) => g.status !== 'expired');
4055
4059
 
4056
- const statusHtml = lic.valid
4057
- ? `<span class="tag ok">● Активна: ${esc(u?.planName || lic.planName)}</span>`
4058
- : `<span class="tag no">⛔ ${lic.expired ? 'Срок действия истёк' : 'Лицензия не установлена'} — торговля отключена</span>`;
4059
-
4060
- const validStr = lic.valid
4061
- ? (lic.perpetual
4062
- ? 'действует <b>бессрочно</b> (100% автономно без запросов к сайту)'
4063
- : `действует автономно до <b>${fmtDate(lic.validUntil)}</b>`)
4064
- : 'привяжите токен из личного кабинета и загрузите оплаченные лицензии ниже';
4060
+ const applyActivatedState = async () => {
4061
+ state.config = await api('/api/config');
4062
+ updateTabBadges();
4063
+ await renderCloudPane();
4064
+ await loadBrokers().catch(() => {});
4065
+ await loadLlm().catch(() => {});
4066
+ if (isOwnerUser()) await loadRuns().catch(() => {});
4067
+ if (isLicensed()) refreshForecastIfStale();
4068
+ else loadForecast(false, true);
4069
+ };
4065
4070
 
4071
+ // Квота счетов (Плитка 1)
4072
+ const maxAcc = u?.limits?.maxBrokerAccounts ?? 0;
4066
4073
  const capLimStr = u?.limits?.maxCapitalPerAccountRub >= 100000000
4067
- ? 'без ограничений'
4074
+ ? 'без ограничений по капиталу'
4068
4075
  : u?.limits?.maxCapitalPerAccountRub > 0
4069
- ? `до ${fmtNum(u.limits.maxCapitalPerAccountRub, 0)} ₽ на счёт (+ вся прибыль)`
4070
- : '—';
4071
- const dirLimStr = lic.valid
4072
- ? (u?.limits?.allowShorts ? 'покупки и шорты (Long / Short)' : 'только покупки (Long)')
4073
- : 'торговля отключена';
4074
-
4075
- const slotsTableHtml = slots.length
4076
- ? `<div style="margin-top:10px;overflow-x:auto">
4077
- <table style="width:100%;font-size:13px">
4078
- <thead><tr><th>Лицензия</th><th>Тариф</th><th>Статус и срок (1 мес. = 31 сутки)</th><th>Устройство</th></tr></thead>
4079
- <tbody>${slots.map((s) => {
4080
- const isThisPc = s.id === lic.licId;
4081
- const stBadge = s.revoked
4082
- ? '<span class="tag no">отозвана</span>'
4083
- : s.perpetual
4084
- ? '<span class="tag ok">бессрочно</span>'
4085
- : s.activated
4086
- ? `<span class="tag ok">активна до ${fmtDate(s.validUntil)}</span>`
4087
- : `<span class="tag mid">в очереди (${s.totalRemainingDays || 0} сут.)</span>`;
4088
- const grantsStr = (s.grants || []).filter((g) => g.status !== 'expired').map((g) => `${esc(g.planName)} (${g.durationDays === 0 ? '∞' : `${g.remainingDays ?? g.durationDays} сут.`})`).join(' → ');
4089
- return `<tr${isThisPc ? ' style="background:var(--accent-bg)"' : ''}>
4090
- <td><b>${esc(s.label || s.id)}</b> ${isThisPc ? '<span class="tag ok">этот терминал</span>' : ''}</td>
4091
- <td><b>${esc(s.planName)}</b></td>
4092
- <td>${stBadge}${grantsStr ? `<div class="muted small">${grantsStr}</div>` : ''}</td>
4093
- <td class="small">${s.bound ? `💻 ${esc(s.hostname || 'ПК')}` : '<span class="muted">свободна</span>'}</td>
4094
- </tr>`;
4095
- }).join('')}</tbody>
4096
- </table>
4097
- </div>`
4098
- : '<div class="muted small" style="margin-top:6px">Загруженных лицензий пока нет.</div>';
4076
+ ? `до ${fmtNum(u.limits.maxCapitalPerAccountRub, 0)} ₽ на счёт`
4077
+ : 'требуется лицензия';
4078
+
4079
+ // Торговый контур (Плитка 2)
4080
+ const tradeOn = Boolean(lic.valid);
4081
+ const tradeSub = tradeOn
4082
+ ? (u?.limits?.allowShorts ? 'Покупки и шорты (Long / Short)' : 'Только покупки (Long)')
4083
+ : 'Привяжите токен из кабинета';
4084
+
4085
+ // Строка токена (внизу Карточки 1 — точно как в эталоне WORK)
4086
+ const tokenRowHtml = lic.hasCabinetToken
4087
+ ? `<div class="lic-token-row">
4088
+ <input type="text" readonly value="${esc(lic.maskedToken || 'na_tok_…')}" title="Привязанный токен личного кабинета">
4089
+ <button type="button" class="btn-unlink-soft" id="btn-lic-detach">Отвязать токен</button>
4090
+ </div>`
4091
+ : `<form id="form-lic-bind" class="lic-token-row">
4092
+ <input id="lic-token-in" name="token" type="text" placeholder="Вставьте токен привязки na_tok_… из личного кабинета" required spellcheck="false">
4093
+ <button type="submit" class="primary" style="height:38px;padding:0 18px">Загрузить лицензии</button>
4094
+ </form>`;
4095
+
4096
+ // Таблица установленных пакетов лицензий (Карточка 2)
4097
+ const rowsHtml = allGrants.length
4098
+ ? allGrants.map((g) => {
4099
+ const expText = g.durationDays === 0
4100
+ ? 'Бессрочно (постоянная)'
4101
+ : (g.endsAt ? `до ${fmtDate(g.endsAt)} (${g.remainingDays ?? g.durationDays} сут.)` : `${g.remainingDays ?? g.durationDays} сут. (в очереди)`);
4102
+ const stText = g.status === 'active' ? '<span class="pos">Активна</span>' : '<span class="muted">В очереди</span>';
4103
+ return `<tr>
4104
+ <td><b>Лицензия «${esc(g.planName)}»</b></td>
4105
+ <td class="muted small">${esc(mainSlot?.hostname || mainSlot?.id || 'этот ПК')}</td>
4106
+ <td><b>${esc(g.plan === 'owner' ? 'Без ограничений' : g.plan === 'vip' ? 'до 50 счетов' : g.plan === 'pro' ? 'до 5 счетов' : '1 счёт')}</b></td>
4107
+ <td>${expText}</td>
4108
+ <td>${stText}</td>
4109
+ </tr>`;
4110
+ }).join('')
4111
+ : (lic.valid
4112
+ ? `<tr>
4113
+ <td><b>Лицензия «${esc(u?.planName || lic.planName)}»</b></td>
4114
+ <td class="muted small">${esc(lic.licId || 'этот ПК')}</td>
4115
+ <td><b>до ${maxAcc} счетов</b></td>
4116
+ <td>${lic.perpetual ? 'Бессрочно (постоянная)' : `до ${fmtDate(lic.validUntil)}`}</td>
4117
+ <td><span class="pos">Активна</span></td>
4118
+ </tr>`
4119
+ : `<tr><td colspan="5" class="muted small">Установленных лицензий нет — перейдите в личный кабинет по кнопке выше, скопируйте токен <code>na_tok_…</code> и загрузите лицензию.</td></tr>`);
4120
+
4121
+ const packagesCount = allGrants.length || (lic.valid ? 1 : 0);
4099
4122
 
4100
4123
  box.innerHTML = `
4101
- <div class="srow sblock" style="${!lic.valid ? 'border:1px solid #ef4444;border-radius:10px;padding:14px 16px;background:rgba(211,47,47,0.08)' : ''}">
4102
- <div class="stxt">
4103
- <div class="slbl">Состояние лицензии терминала: ${statusHtml}</div>
4104
- <div class="hint">
4105
- Срок: ${validStr}${lic.email ? ` · Аккаунт: <b>${esc(lic.email)}</b>` : ''}<br>
4106
- ${lic.valid ? `Квота счетов: до <b>${u?.limits?.maxBrokerAccounts ?? 0}</b> (${capLimStr}) · Режим: <b>${dirLimStr}</b>.` : 'Без действующей лицензии любые торговые операции и живой радар сигналов заблокированы.'}
4124
+ <div class="lic-card">
4125
+ <div style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap">
4126
+ <div style="font-size:16px;font-weight:700">
4127
+ ◈ Лицензирование НейроАльфа &nbsp;<span style="font-weight:600;color:${lic.valid ? 'var(--green)' : 'var(--red)'}">${lic.valid ? 'Лицензия активна · Торговля включена' : (lic.expired ? 'Срок лицензии истёк · Торговля отключена' : 'Лицензия не установлена · Торговля отключена')}</span>
4107
4128
  </div>
4129
+ <button type="button" id="btn-edit-portal-url" class="small" style="padding:3px 9px" title="Изменить адрес сервера лицензий">Адрес портала</button>
4108
4130
  </div>
4109
- <div class="row-gap" style="flex-wrap:wrap">
4110
- ${lic.hasCabinetToken ? '<button type="button" class="primary" id="btn-lic-refresh">↻ Обновить лицензии с сайта</button>' : ''}
4111
- ${lic.hasCabinetToken ? '<button type="button" id="btn-lic-detach" style="color:var(--red)">Отключить токен</button>' : ''}
4112
- ${!me.isLoopback ? '<button type="button" id="btn-term-logout" style="color:var(--red)">Выйти из сессии</button>' : ''}
4113
- </div>
4114
- </div>
4115
4131
 
4116
- <div class="srow sblock">
4117
- <div class="stxt">
4118
- <div class="slbl">Шаг 1. Перейдите на сайт портала и получите токен личного кабинета</div>
4119
- <div class="hint">Войдите в профиль на сайте по <b>Email + одноразовому коду</b>, оформите нужную лицензию и скопируйте ваш персональный токен <code>na_tok_…</code>.</div>
4120
- </div>
4121
- <div class="row-gap" style="flex-wrap:wrap">
4122
- <input id="lic-portal-url" value="${esc(portalUrl)}" placeholder="https://neuroalpha.odant.org" style="width:250px" title="Адрес портала лицензий">
4123
- <a id="btn-open-portal" class="btn primary" href="${esc(portalUrl)}" target="_blank" rel="noopener">🌐 Открыть сайт / Личный кабинет →</a>
4132
+ <div style="margin-top:10px;display:flex;gap:8px;align-items:center;flex-wrap:wrap">
4133
+ <a id="btn-open-portal" class="btn" href="${esc(portalUrl)}/#cabinet" target="_blank" rel="noopener" style="border-color:var(--accent);color:var(--accent);font-weight:600">
4134
+ Личный кабинет (${esc(portalHost)}) ↗
4135
+ </a>
4136
+ <input id="lic-portal-url" value="${esc(portalUrl)}" placeholder="https://neuroalpha.odant.org" hidden style="width:260px;height:32px">
4124
4137
  </div>
4125
- </div>
4126
4138
 
4127
- <div class="srow sblock">
4128
- <div class="stxt">
4129
- <div class="slbl">Шаг 2. Привяжите токен и загрузите оплаченные лицензии</div>
4130
- <div class="hint">Вставьте скопированный из личного кабинета токен <code>na_tok_…</code> — терминал скачает подписанные криптоключом лицензии и включит работу автономно на весь оплаченный срок.</div>
4131
- <div id="lic-slot-choice" hidden style="margin-top:10px;display:grid;gap:6px"></div>
4139
+ <div class="lic-stats-grid">
4140
+ <div class="lic-stat-tile">
4141
+ <div class="lic-stat-lbl">Тариф и квота счетов</div>
4142
+ <div class="lic-stat-val" style="color:${lic.valid ? 'var(--accent)' : 'var(--muted)'}">${lic.valid ? `${esc(u?.planName || lic.planName)} (${maxAcc >= 100 ? '∞' : `до ${maxAcc}`})` : 'Не активен'}</div>
4143
+ <div class="lic-stat-sub">${esc(capLimStr)}</div>
4144
+ </div>
4145
+ <div class="lic-stat-tile">
4146
+ <div class="lic-stat-lbl">Торговый контур и сигналы</div>
4147
+ <div class="lic-stat-val">${tradeOn ? 'Включены' : 'Отключены'}</div>
4148
+ <div class="lic-stat-sub">${esc(tradeSub)}</div>
4149
+ </div>
4150
+ <div class="lic-stat-tile">
4151
+ <div class="lic-stat-lbl">Аккаунт владельца и портал</div>
4152
+ <div class="lic-stat-val" style="font-size:14.5px;word-break:break-all">${esc(lic.email || 'Токен не привязан')}</div>
4153
+ <div class="lic-stat-sub"><a href="${esc(portalUrl)}/#cabinet" target="_blank" rel="noopener">${esc(portalUrl)} ↗</a></div>
4154
+ </div>
4132
4155
  </div>
4133
- <form id="form-lic-bind" class="row-gap" style="flex-wrap:wrap">
4134
- <input id="lic-token-in" name="token" placeholder="na_tok_…" style="width:300px;font-family:monospace" required spellcheck="false">
4135
- <button type="submit" class="primary" id="btn-lic-download">⬇ Загрузить лицензии</button>
4136
- </form>
4137
- </div>
4138
4156
 
4139
- <div class="srow sblock" style="display:block">
4140
- <div class="slbl">Загруженные лицензии и очередь пакетов</div>
4141
- ${slotsTableHtml}
4157
+ ${tokenRowHtml}
4142
4158
  </div>
4143
4159
 
4144
- <div class="srow sblock">
4145
- <div class="stxt">
4146
- <div class="slbl">Защита удалённого доступа (для работы на арендованной виртуалке / VPS)</div>
4147
- <div class="hint">
4148
- На локальном ПК (<code>localhost</code>) интерфейс открыт сразу. При обращении по внешнему IP сервер требует ваш токен <code>na_tok_…</code> либо дополнительный локальный пароль (${lic.hasLocalPassword ? '<b class="pos">установлен ✓</b>' : 'не задан'}).
4160
+ <div class="lic-card">
4161
+ <div style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:14px">
4162
+ <div style="font-size:15.5px;font-weight:700">📦 Установленные пакеты лицензий</div>
4163
+ <div class="row-gap">
4164
+ <button type="button" id="btn-lic-refresh" ${!lic.hasCabinetToken ? 'disabled' : ''}>Проверить обновления лицензий</button>
4165
+ <span class="muted small">${packagesCount} пакет(ов)</span>
4149
4166
  </div>
4150
4167
  </div>
4151
- <form id="form-local-pass" class="row-gap" style="flex-wrap:wrap">
4152
- <input name="password" type="password" autocomplete="new-password" placeholder="${lic.hasLocalPassword ? 'Новый пароль (пусто — сбросить)' : 'Задать пароль (от 6 симв.)'}" style="width:240px">
4153
- <button type="submit">Сохранить пароль VPS</button>
4154
- </form>
4155
- </div>
4156
4168
 
4157
- <div class="srow sblock">
4158
- <div class="stxt">
4159
- <div class="slbl">Уведомления и общение со Стратегом в Telegram и MAX</div>
4160
- <div class="hint">
4161
- По умолчанию уведомления и кнопки подтверждения заявок работают через ботов портала (привяжите Telegram или MAX в личном кабинете на сайте). При желании ниже можно подключить собственного бота напрямую.
4162
- </div>
4163
- </div>
4164
- <div class="row-gap" style="flex-wrap:wrap">
4165
- <button type="button" id="btn-notify-test">✉ Отправить тест уведомления</button>
4169
+ <div style="overflow-x:auto">
4170
+ <table style="width:100%;font-size:13px">
4171
+ <thead>
4172
+ <tr>
4173
+ <th>ТАРИФ ЛИЦЕНЗИИ</th>
4174
+ <th>УСТРОЙСТВО</th>
4175
+ <th>КВОТА СЧЕТОВ</th>
4176
+ <th>СРОК ДЕЙСТВИЯ</th>
4177
+ <th>СТАТУС</th>
4178
+ </tr>
4179
+ </thead>
4180
+ <tbody>${rowsHtml}</tbody>
4181
+ </table>
4166
4182
  </div>
4167
4183
  </div>
4168
-
4169
- <details style="margin-top:8px;padding-top:10px;border-top:1px solid var(--line)">
4170
- <summary class="muted small" style="cursor:pointer;font-weight:500">⚙ Прямое подключение собственного личного бота Telegram / MAX (автономно без портала)</summary>
4171
- <form id="direct-notify-form" style="display:grid;gap:10px;margin-top:10px">
4172
- <label class="row-gap small"><input type="checkbox" name="usePortalRelay" ${nt.usePortalRelay !== false ? 'checked' : ''}> Разрешить отправку через ботов портала, если прямой бот не заполнен</label>
4173
- <div class="row-gap" style="flex-wrap:wrap">
4174
- <input name="tgToken" type="text" class="secret-input" autocomplete="off" placeholder="${nt.hasDirectTg ? 'Токен личного Telegram-бота задан' : 'Токен Telegram Bot API (@BotFather)'}" style="flex:1;min-width:240px">
4175
- <input name="tgChatId" value="${esc(nt.tgChatId || '')}" placeholder="Telegram Chat ID (заполнится при /start)" style="width:240px">
4176
- </div>
4177
- <div class="row-gap" style="flex-wrap:wrap">
4178
- <input name="maxToken" type="text" class="secret-input" autocomplete="off" placeholder="${nt.hasDirectMax ? 'Токен личного MAX-бота задан' : 'Токен MAX Bot API'}" style="flex:1;min-width:240px">
4179
- <input name="maxChatId" value="${esc(nt.maxChatId || '')}" placeholder="MAX Chat ID" style="width:240px">
4180
- <button type="submit" class="primary">Сохранить прямого бота</button>
4181
- </div>
4182
- </form>
4183
- </details>
4184
4184
  `;
4185
4185
 
4186
4186
  const portalInput = box.querySelector('#lic-portal-url');
4187
4187
  const openPortalLink = box.querySelector('#btn-open-portal');
4188
+ box.querySelector('#btn-edit-portal-url')?.addEventListener('click', () => {
4189
+ if (portalInput) {
4190
+ portalInput.hidden = !portalInput.hidden;
4191
+ if (!portalInput.hidden) portalInput.focus();
4192
+ }
4193
+ });
4188
4194
  portalInput?.addEventListener('input', () => {
4189
- const v = portalInput.value.trim() || 'https://neuroalpha.odant.org';
4190
- if (openPortalLink && /^https?:\/\//i.test(v)) openPortalLink.href = v;
4195
+ const v = (portalInput.value.trim() || 'https://neuroalpha.odant.org').replace(/\/+$/, '');
4196
+ if (openPortalLink && /^https?:\/\//i.test(v)) {
4197
+ openPortalLink.href = `${v}/#cabinet`;
4198
+ openPortalLink.textContent = `Личный кабинет (${v.replace(/^https?:\/\//, '')}) ↗`;
4199
+ }
4191
4200
  });
4192
4201
 
4193
- const applyActivatedState = async () => {
4194
- state.config = await api('/api/config');
4195
- updateTabBadges();
4196
- await renderCloudPane();
4197
- await loadBrokers().catch(() => {});
4198
- await loadLlm().catch(() => {});
4199
- if (isOwnerUser()) await loadRuns().catch(() => {});
4200
- if (isLicensed()) refreshForecastIfStale();
4201
- else loadForecast(false, true);
4202
- };
4203
-
4204
4202
  box.querySelector('#btn-lic-refresh')?.addEventListener('click', async () => {
4205
4203
  try {
4206
- const pUrl = portalInput?.value.trim() || portalUrl;
4204
+ const pUrl = (portalInput?.value.trim() || portalUrl).replace(/\/+$/, '');
4207
4205
  const r = await post('/api/local/refresh-license', { portalUrl: pUrl });
4208
- toast(`✓ Лицензии обновлены: ${r.state?.planName || 'активна'}`);
4206
+ toast(`✓ Лицензии проверены: ${r.state?.planName || 'активна'}`);
4209
4207
  await applyActivatedState();
4210
4208
  } catch (e) { toast(e.message, 'error'); }
4211
4209
  });
4212
4210
 
4213
4211
  box.querySelector('#btn-lic-detach')?.addEventListener('click', async () => {
4214
- if (!confirm('Отключить токен и удалить локальный сертификат лицензии с этого компьютера? Торговля сразу остановится.')) return;
4212
+ if (!confirm('Отвязать токен с этого терминала? Торговля и живой радар сразу остановятся.')) return;
4215
4213
  try {
4216
4214
  await post('/api/local/detach-license', {});
4217
- toast('Лицензия отключена — торговля остановлена');
4215
+ toast('Токен отвязан — торговля отключена');
4218
4216
  await applyActivatedState();
4219
4217
  } catch (e) { toast(e.message, 'error'); }
4220
4218
  });
4221
4219
 
4222
4220
  box.querySelector('#form-lic-bind')?.addEventListener('submit', async (e) => {
4223
4221
  e.preventDefault();
4224
- const f = e.target.elements;
4225
- const pUrl = portalInput?.value.trim() || portalUrl;
4226
- const pickedSlot = box.querySelector('input[name="pick_slot_id"]:checked')?.value || '';
4222
+ const tok = e.target.elements.token.value.trim();
4223
+ const pUrl = (portalInput?.value.trim() || portalUrl).replace(/\/+$/, '');
4227
4224
  try {
4228
- const r = await post('/api/local/activate', {
4229
- token: f.token.value.trim(),
4230
- portalUrl: pUrl,
4231
- slotId: pickedSlot,
4232
- });
4233
- if (r.chooseSlot && Array.isArray(r.slots) && r.slots.length) {
4234
- const slotBox = box.querySelector('#lic-slot-choice');
4235
- slotBox.hidden = false;
4236
- slotBox.innerHTML = `<div class="small" style="font-weight:600">Выберите свободную лицензию для этого терминала и нажмите «Загрузить лицензии» ещё раз:</div>`
4237
- + r.slots.map((s, i) => `<label class="row-gap small" style="padding:6px 10px;border:1px solid var(--line);border-radius:7px;cursor:pointer">
4238
- <input type="radio" name="pick_slot_id" value="${esc(s.id)}" ${i === 0 ? 'checked' : ''}>
4239
- <span><b>${esc(s.label || s.id)}</b> · ${esc(s.planName)} (${s.perpetual ? 'бессрочно' : `${s.totalRemainingDays || 0} сут.`})</span>
4240
- </label>`).join('');
4241
- toast('Выберите нужный слот лицензии в списке');
4242
- return;
4243
- }
4244
- f.token.value = '';
4245
- toast(`✓ Лицензия «${r.state?.planName || ''}» успешно загружена и активирована!`);
4225
+ const r = await post('/api/local/activate', { token: tok, portalUrl: pUrl });
4226
+ toast(`✓ Лицензия «${r.state?.planName || ''}» установлена!`);
4246
4227
  await applyActivatedState();
4247
4228
  } catch (err) { toast(err.message, 'error'); }
4248
4229
  });
4230
+ }
4231
+
4232
+ // ---------- Отдельный чистый раздел: Уведомления (единые боты платформы Telegram / MAX) и пароль VPS ----------
4233
+ async function renderAccessPane() {
4234
+ const box = $('#access-pane');
4235
+ if (!box) return;
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
+ ]);
4240
+ state.config = { ...(state.config || {}), ...me };
4241
+ const lic = me.license || me.user?.license || {};
4242
+ const portalUrl = lic.portalUrl || 'https://neuroalpha.odant.org';
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
+
4293
+ box.innerHTML = `
4294
+ <div class="lic-card">
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>
4300
+ </div>
4301
+ </div>
4302
+ ${botsContentHtml}
4303
+ </div>
4304
+
4305
+ <div class="lic-card">
4306
+ <div style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:8px">
4307
+ <div style="font-size:15.5px;font-weight:700">🔐 Пароль для удалённого доступа (при работе на VPS)</div>
4308
+ ${!me.isLoopback ? '<button type="button" id="btn-term-logout" class="btn-unlink-soft" style="height:32px">Выйти из сессии</button>' : ''}
4309
+ </div>
4310
+ <p class="muted small" style="margin:0 0 14px">
4311
+ С локального компьютера (<code>localhost</code>) терминал открывается без пароля. При входе по внешнему IP сервер запрашивает ваш токен <code>na_tok_…</code> либо заданный здесь локальный пароль (статус: ${lic.hasLocalPassword ? '<b class="pos">установлен ✓</b>' : 'не задан'}).
4312
+ </p>
4313
+ <form id="form-local-pass" class="row-gap" style="flex-wrap:wrap">
4314
+ <input name="password" type="password" autocomplete="new-password" placeholder="${lic.hasLocalPassword ? 'Новый пароль (пусто — сбросить)' : 'Задать пароль (от 6 симв.)'}" style="width:260px">
4315
+ <button type="submit">Сохранить пароль</button>
4316
+ </form>
4317
+ </div>
4318
+ `;
4319
+
4320
+ box.querySelector('#btn-bots-refresh')?.addEventListener('click', () => renderAccessPane());
4321
+ box.querySelector('#btn-notify-test')?.addEventListener('click', async () => {
4322
+ try { await post('/api/notify/test'); toast('✓ Тестовое уведомление отправлено'); }
4323
+ catch (e) { toast(e.message, 'error'); }
4324
+ });
4325
+ box.querySelectorAll('[data-unbind-bot]').forEach((b) => b.addEventListener('click', async () => {
4326
+ try {
4327
+ await post('/api/notify/unbind', { channel: b.dataset.unbindBot });
4328
+ toast('Мессенджер отвязан');
4329
+ renderAccessPane();
4330
+ } catch (e) { toast(e.message, 'error'); }
4331
+ }));
4249
4332
 
4250
4333
  box.querySelector('#form-local-pass')?.addEventListener('submit', async (e) => {
4251
4334
  e.preventDefault();
@@ -4253,8 +4336,8 @@ async function renderCloudPane() {
4253
4336
  try {
4254
4337
  const r = await post('/api/local/password', { password: pw });
4255
4338
  e.target.reset();
4256
- toast(r.hasLocalPassword ? '✓ Локальный пароль для удалённого входа сохранён' : 'Локальный пароль сброшен');
4257
- renderCloudPane();
4339
+ toast(r.hasLocalPassword ? '✓ Пароль сохранён' : 'Пароль сброшен');
4340
+ renderAccessPane();
4258
4341
  } catch (err) { toast(err.message, 'error'); }
4259
4342
  });
4260
4343
 
@@ -4262,29 +4345,6 @@ async function renderCloudPane() {
4262
4345
  await post('/api/auth/logout').catch(() => {});
4263
4346
  location.href = '/';
4264
4347
  });
4265
-
4266
- box.querySelector('#btn-notify-test')?.addEventListener('click', async () => {
4267
- try {
4268
- await post('/api/notify/test');
4269
- toast('✓ Тестовое уведомление доставлено');
4270
- } catch (e) { toast(e.message, 'error'); }
4271
- });
4272
-
4273
- box.querySelector('#direct-notify-form')?.addEventListener('submit', async (e) => {
4274
- e.preventDefault();
4275
- const f = e.target.elements;
4276
- try {
4277
- await post('/api/notify/config', {
4278
- usePortalRelay: f.usePortalRelay.checked,
4279
- tgToken: f.tgToken.value,
4280
- tgChatId: f.tgChatId.value,
4281
- maxToken: f.maxToken.value,
4282
- maxChatId: f.maxChatId.value,
4283
- });
4284
- toast('✓ Настройки уведомлений сохранены');
4285
- renderCloudPane();
4286
- } catch (err) { toast(err.message, 'error'); }
4287
- });
4288
4348
  }
4289
4349
 
4290
4350
  // ---------- тема (тёмная / светлая) ----------
@@ -4860,7 +4920,7 @@ document.addEventListener('keydown', (e) => {
4860
4920
  showMainSec('cloud');
4861
4921
  } else if (urlParams.get('run') && isOwnerUser()) {
4862
4922
  showMainSec('overview');
4863
- } else if (savedNav.mainSec && ['brokers', 'forecast', 'news', 'overview', 'cloud', 'llm'].includes(savedNav.mainSec)) {
4923
+ } else if (savedNav.mainSec && ['brokers', 'forecast', 'news', 'overview', 'cloud', 'access', 'llm'].includes(savedNav.mainSec)) {
4864
4924
  showMainSec(savedNav.mainSec);
4865
4925
  } else {
4866
4926
  showMainSec('brokers');
package/public/index.html CHANGED
@@ -93,6 +93,7 @@
93
93
  <div class="ssep"></div>
94
94
  <button type="button" data-sec="overview" id="nnav-overview" hidden>Эксперименты</button>
95
95
  <button type="button" data-sec="cloud" id="snav-cloud">Лицензия</button>
96
+ <button type="button" data-sec="access" id="snav-access">Уведомления и доступ</button>
96
97
  <button type="button" data-sec="llm" id="snav-llm">Модели ИИ</button>
97
98
  </aside>
98
99
  <div class="spane">
@@ -249,8 +250,11 @@
249
250
  </section>
250
251
 
251
252
  <section class="ssec" data-sec="cloud" id="cloud-box" hidden>
252
- <div class="stitle">Лицензия терминала, доступ и уведомления (Telegram / MAX)</div>
253
- <div class="scard spadded" id="cloud-pane"></div>
253
+ <div class="lic-page-wrap" id="cloud-pane"></div>
254
+ </section>
255
+
256
+ <section class="ssec" data-sec="access" id="access-box" hidden>
257
+ <div class="lic-page-wrap" id="access-pane"></div>
254
258
  </section>
255
259
 
256
260
  <section class="ssec" data-sec="llm" id="llm-box" hidden>
package/public/styles.css CHANGED
@@ -98,6 +98,62 @@ body.light .hdr-btn.lic-err {
98
98
  border-color: #dc2626;
99
99
  color: #b91c1c;
100
100
  }
101
+ summary:focus, summary:focus-visible { outline: none; }
102
+ .lic-page-wrap {
103
+ max-width: 860px;
104
+ margin: 6px auto 28px;
105
+ display: grid;
106
+ gap: 18px;
107
+ }
108
+ .lic-card {
109
+ background: var(--panel);
110
+ border: 1px solid var(--line);
111
+ border-radius: 14px;
112
+ padding: 22px 24px;
113
+ }
114
+ .lic-stats-grid {
115
+ display: grid;
116
+ grid-template-columns: repeat(3, minmax(0, 1fr));
117
+ gap: 12px;
118
+ margin: 16px 0;
119
+ }
120
+ @media (max-width: 780px) { .lic-stats-grid { grid-template-columns: 1fr; } }
121
+ .lic-stat-tile {
122
+ border: 1px solid var(--line);
123
+ border-radius: 10px;
124
+ padding: 12px 14px;
125
+ background: var(--bg);
126
+ }
127
+ .lic-stat-lbl { font-size: 11.5px; color: var(--muted); margin-bottom: 4px; }
128
+ .lic-stat-val { font-size: 16px; font-weight: 700; color: var(--text); }
129
+ .lic-stat-sub { font-size: 12px; color: var(--muted); margin-top: 3px; }
130
+ .lic-token-row {
131
+ display: grid;
132
+ grid-template-columns: minmax(0, 1fr) auto;
133
+ gap: 10px;
134
+ align-items: center;
135
+ }
136
+ .lic-token-row input {
137
+ height: 38px;
138
+ padding: 8px 12px;
139
+ font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
140
+ font-size: 13px;
141
+ background: var(--bg);
142
+ }
143
+ button.btn-unlink-soft {
144
+ height: 38px;
145
+ padding: 0 16px;
146
+ background: rgba(244, 63, 94, 0.1);
147
+ border-color: rgba(244, 63, 94, 0.35);
148
+ color: #e11d48;
149
+ font-weight: 600;
150
+ }
151
+ body:not(.light) button.btn-unlink-soft { color: #fb7185; }
152
+ button.btn-unlink-soft:hover:not(:disabled) {
153
+ background: rgba(244, 63, 94, 0.18);
154
+ border-color: #e11d48;
155
+ color: #e11d48;
156
+ }
101
157
  .brand { display: flex; gap: 8px; align-items: center; }
102
158
  .logo { color: var(--accent); font-size: 18px; }
103
159
  .hright { display: flex; gap: 8px; align-items: center; }
@@ -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
  }));
@@ -215,6 +215,8 @@ export function verifyLicenseCertificate(licenseToken, publicKeyPem, expectedIns
215
215
  export function getTerminalLicenseState() {
216
216
  const stored = kvGet('terminalLicense') || null;
217
217
  const localPassHash = kvGet('terminalLocalPassHash') || '';
218
+ const rawTok = decryptSecret(stored?.cabinetTokenEnc || '');
219
+ const maskedToken = rawTok ? `${rawTok.slice(0, 22)}…${rawTok.slice(-8)}` : '';
218
220
  if (!stored?.licenseToken || !stored?.publicKeyPem) {
219
221
  return {
220
222
  activated: false,
@@ -229,7 +231,8 @@ export function getTerminalLicenseState() {
229
231
  licId: '',
230
232
  label: '',
231
233
  portalUrl: stored?.portalUrl || DEFAULT_CLOUD_URL,
232
- hasCabinetToken: Boolean(stored?.cabinetTokenEnc),
234
+ hasCabinetToken: Boolean(rawTok),
235
+ maskedToken,
233
236
  hasLocalPassword: Boolean(localPassHash),
234
237
  installId: getInstallId(),
235
238
  slots: Array.isArray(stored?.allSlots) ? stored.allSlots : [],
@@ -256,7 +259,8 @@ export function getTerminalLicenseState() {
256
259
  licId: p.licId || stored.slotId || '',
257
260
  label: p.label || '',
258
261
  portalUrl: stored.portalUrl || p.portalUrl || DEFAULT_CLOUD_URL,
259
- hasCabinetToken: Boolean(stored.cabinetTokenEnc),
262
+ hasCabinetToken: Boolean(rawTok),
263
+ maskedToken,
260
264
  hasLocalPassword: Boolean(localPassHash),
261
265
  installId: getInstallId(),
262
266
  lastFetchedAt: stored.fetchedAt || 0,
@@ -277,7 +281,8 @@ export function getTerminalLicenseState() {
277
281
  licId: '',
278
282
  label: '',
279
283
  portalUrl: stored.portalUrl || DEFAULT_CLOUD_URL,
280
- hasCabinetToken: Boolean(stored.cabinetTokenEnc),
284
+ hasCabinetToken: Boolean(rawTok),
285
+ maskedToken,
281
286
  hasLocalPassword: Boolean(localPassHash),
282
287
  installId: getInstallId(),
283
288
  slots: Array.isArray(stored.allSlots) ? stored.allSlots : [],
@@ -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
  }