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 +1 -1
- package/public/app.js +234 -174
- package/public/index.html +6 -2
- package/public/styles.css +56 -0
- package/server/api/routes.js +9 -5
- package/server/auth/index.js +8 -3
- 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
|
@@ -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
|
|
4057
|
-
|
|
4058
|
-
|
|
4059
|
-
|
|
4060
|
-
|
|
4061
|
-
|
|
4062
|
-
|
|
4063
|
-
|
|
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
|
-
|
|
4072
|
-
|
|
4073
|
-
|
|
4074
|
-
|
|
4075
|
-
|
|
4076
|
-
|
|
4077
|
-
|
|
4078
|
-
|
|
4079
|
-
|
|
4080
|
-
|
|
4081
|
-
|
|
4082
|
-
|
|
4083
|
-
|
|
4084
|
-
|
|
4085
|
-
|
|
4086
|
-
|
|
4087
|
-
|
|
4088
|
-
|
|
4089
|
-
|
|
4090
|
-
|
|
4091
|
-
|
|
4092
|
-
|
|
4093
|
-
|
|
4094
|
-
|
|
4095
|
-
|
|
4096
|
-
|
|
4097
|
-
|
|
4098
|
-
|
|
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="
|
|
4102
|
-
<div
|
|
4103
|
-
<div
|
|
4104
|
-
|
|
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
|
+
◈ Лицензирование НейроАльфа <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
|
-
|
|
4117
|
-
|
|
4118
|
-
|
|
4119
|
-
|
|
4120
|
-
|
|
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
|
-
|
|
4128
|
-
|
|
4129
|
-
|
|
4130
|
-
|
|
4131
|
-
|
|
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
|
-
|
|
4140
|
-
<div class="slbl">Загруженные лицензии и очередь пакетов</div>
|
|
4141
|
-
${slotsTableHtml}
|
|
4157
|
+
${tokenRowHtml}
|
|
4142
4158
|
</div>
|
|
4143
4159
|
|
|
4144
|
-
<div class="
|
|
4145
|
-
<div
|
|
4146
|
-
<div
|
|
4147
|
-
<div class="
|
|
4148
|
-
|
|
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
|
-
|
|
4158
|
-
|
|
4159
|
-
|
|
4160
|
-
|
|
4161
|
-
|
|
4162
|
-
|
|
4163
|
-
|
|
4164
|
-
|
|
4165
|
-
|
|
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))
|
|
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(`✓ Лицензии
|
|
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('
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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="
|
|
253
|
-
|
|
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; }
|
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/auth/index.js
CHANGED
|
@@ -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(
|
|
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(
|
|
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(
|
|
284
|
+
hasCabinetToken: Boolean(rawTok),
|
|
285
|
+
maskedToken,
|
|
281
286
|
hasLocalPassword: Boolean(localPassHash),
|
|
282
287
|
installId: getInstallId(),
|
|
283
288
|
slots: Array.isArray(stored.allSlots) ? stored.allSlots : [],
|
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
|
}
|