work-license-server 0.0.0-stage → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,18 @@
1
- # Temporary Holding Version
1
+ # WORK License Server (`work.odant.org`)
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Служебный пакет лицензионного сервера, платёжного шлюза ЮKassa, омниканальных ботов техподдержки (`MAX`, `Telegram`, `VK`) и лендинга программно-аппаратного комплекса **ПАК WORK** (`https://work.odant.org`).
4
+
5
+ ## Назначение
6
+
7
+ 1. **Лендинг и конфигуратор ПАК WORK (`/`):**
8
+ - Аппаратная база: **AMD Ryzen 495 8085 · 192 ГБ RAM · SSD 4 ТБ**.
9
+ - Предустановленная типовая учётная конфигурация (аналог 1С) + 4 локальные модели ИИ (`Qwen 3 30B`, `Голосовая ASR/TTS`, `Генерация изображений`, `Embedding`).
10
+ - Базовый комплект (`5 000 000 ₽`): включает **вечную лицензию на 5 одновременных активных сессий** и **1-й год поддержки**.
11
+ 2. **Срочные годовые лицензии на дополнительных пользователей (по уровням SLA):**
12
+ - **Start** — `15 000 ₽ / год` (до 12 обращений/год на место, время реакции до 8 раб. часов).
13
+ - **Business** — `40 000 ₽ / год` (до 48 обращений/год на место, время реакции до 2 часов).
14
+ - **Enterprise** — `100 000 ₽ / год` (безлимит обращений, время реакции до 15 минут 24/7, выделенный инженер).
15
+ 3. **Интеграция ЮKassa (`/api/orders/checkout`, `/api/payments/yookassa/webhook`):**
16
+ - Автоматический выпуск и продление подписанных сертификатов `Ed25519` (`WORK-LIC-v1`) с привязкой к `HWID` процессора/платы AMD Ryzen и ключу узла `#system/node-key.json`.
17
+ 4. **Боты техподдержки (`MAX`, `Telegram`, `VK`):**
18
+ - Webhooks `/api/bots/max/webhook`, `/api/bots/telegram/webhook`, `/api/bots/vk/webhook`.
package/bin/server.mjs ADDED
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Точка запуска лицензионного сервера и лендинга ПАК WORK (work.odant.org).
4
+ */
5
+ import { createLicenseServerApp } from '../src/server.js';
6
+
7
+ const port = Number(process.env.PORT || process.env.WORK_LICENSE_PORT || 8020);
8
+ const dataDir = process.env.WORK_LICENSE_DATA_DIR || './data';
9
+
10
+ const app = createLicenseServerApp({ dataDir });
11
+ const server = await app.listen(port);
12
+ const addr = server.address();
13
+ const actualPort = typeof addr === 'object' && addr ? addr.port : port;
14
+ console.log(`[work.odant.org] Лицензионный сервер и лендинг ПАК WORK запущен: http://localhost:${actualPort}/`);
package/package.json CHANGED
@@ -1,6 +1,31 @@
1
1
  {
2
2
  "name": "work-license-server",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.0",
4
+ "description": "Лицензионный сервер, биллинг ЮKassa, боты поддержки (MAX, Telegram, VK) и лендинг ПАК WORK (work.odant.org)",
5
+ "type": "module",
6
+ "main": "src/index.js",
7
+ "bin": {
8
+ "work-license-server": "bin/server.mjs"
9
+ },
10
+ "files": [
11
+ "bin/",
12
+ "src/",
13
+ "README.md"
14
+ ],
15
+ "scripts": {
16
+ "start": "node ./bin/server.mjs",
17
+ "test": "node --test"
18
+ },
19
+ "keywords": [
20
+ "odant",
21
+ "work",
22
+ "license-server",
23
+ "yookassa",
24
+ "pak"
25
+ ],
26
+ "author": "ODANT",
27
+ "license": "SEE LICENSE IN README.md",
28
+ "engines": {
29
+ "node": ">=22.0.0"
30
+ }
31
+ }
package/src/bots.js ADDED
@@ -0,0 +1,311 @@
1
+ /**
2
+ * Омниканальный шлюз ботов технической поддержки ПАК WORK для мессенджеров:
3
+ * - MAX (https://botapi.max.ru)
4
+ * - Telegram (https://api.telegram.org)
5
+ * - VK (https://api.vk.com/method)
6
+ *
7
+ * Возможности:
8
+ * - Привязка чата к лицензии ПАК клиента (/bind <код_или_номер_лицензии>);
9
+ * - Сводка по ПАК, рабочим местам (5 вечных + годовые) и текущему уровню SLA (/status);
10
+ * - Докупка годовых пользовательских лицензий Start / Business / Enterprise через ЮKassa (/buy);
11
+ * - Приём обращений в техподдержку с проверкой квоты обращений и расчётом времени реакции по SLA:
12
+ * Start (15 000 ₽/год) — до 12 обращений/год на место, реакция до 8 раб. часов;
13
+ * Business (40 000 ₽/год) — до 48 обращений/год на место, реакция до 2 часов;
14
+ * Enterprise (100 000 ₽/год) — безлимит обращений, реакция до 15 минут (24/7);
15
+ * - Отправка ответов инженеров и уведомлений об успешной оплате обратно в MAX / Telegram / VK.
16
+ */
17
+ import { SLA_TIERS } from './catalog.js';
18
+
19
+ export class SupportBotsHub {
20
+ constructor({
21
+ store,
22
+ yookassa,
23
+ maxBotSecret = process.env.MAX_BOT_TOKEN || '',
24
+ telegramBotSecret = process.env.TELEGRAM_BOT_TOKEN || '',
25
+ vkBotSecret = process.env.VK_BOT_TOKEN || '',
26
+ vkConfirmationCode = process.env.VK_CONFIRMATION_CODE || 'work_vk_confirm',
27
+ publicOrigin = process.env.PUBLIC_ORIGIN || 'https://work.odant.org',
28
+ fetchImpl = globalThis.fetch,
29
+ } = {}) {
30
+ this.store = store;
31
+ this.yookassa = yookassa;
32
+ this.maxBotSecret = maxBotSecret;
33
+ this.telegramBotSecret = telegramBotSecret;
34
+ this.vkBotSecret = vkBotSecret;
35
+ this.vkConfirmationCode = vkConfirmationCode;
36
+ this.publicOrigin = String(publicOrigin).replace(/\/+$/, '');
37
+ this.fetchImpl = fetchImpl;
38
+ this.outboxLog = [];
39
+ }
40
+
41
+ /**
42
+ * Универсальная обработка входящего сообщения от пользователя из любого мессенджера (max | telegram | vk).
43
+ */
44
+ async handleIncomingMessage({ channel, chatId, userName = 'Клиент', text = '', originOverride = '' }) {
45
+ const ch = String(channel || 'telegram').toLowerCase();
46
+ const cid = String(chatId || 'demo-chat');
47
+ const cleanText = String(text || '').trim();
48
+
49
+ if (!cleanText || /^\/(start|help|помощь)$/i.test(cleanText)) {
50
+ const bound = this.store.getBoundLicenseForChat(ch, cid);
51
+ const greeting = [
52
+ `Здравствуйте, ${userName}! Это служба поддержки и лицензирования ПАК WORK (${ch.toUpperCase()}).`,
53
+ bound
54
+ ? `Ваш чат привязан к лицензии: ${bound.licenseId} (${bound.customer?.name || 'ПАК WORK'}, уровень ${bound.support.tierName}, мест: ${bound.totalSeats}).`
55
+ : 'Для начала работы привяжите ваш ПАК WORK командой:\n/bind <НОМЕР_ЛИЦЕНЗИИ_ИЛИ_КОД>',
56
+ '',
57
+ 'Доступные команды:',
58
+ '• /bind <КОД> — привязать ПАК WORK к этому чату',
59
+ '• /status — проверить рабочие места (5 вечных + годовые), SLA и остаток обращений',
60
+ '• /buy <start|business|enterprise> <кол-во_мест> — докупить годовые лицензии через ЮKassa',
61
+ '• Любое другое сообщение — создать обращение в инженерную поддержку по вашему SLA.',
62
+ ].join('\n');
63
+ await this.sendExternalMessage(ch, cid, greeting);
64
+ return { action: 'help', reply: greeting };
65
+ }
66
+
67
+ // Команда /bind <код>
68
+ const bindMatch = cleanText.match(/^\/(?:bind|привязать)\s+(\S+)/i);
69
+ if (bindMatch) {
70
+ const code = bindMatch[1].trim();
71
+ try {
72
+ const lic = this.store.bindBotChat({
73
+ channel: ch,
74
+ chatId: cid,
75
+ userName,
76
+ codeOrLicenseId: code,
77
+ });
78
+ const reply = [
79
+ `Чат (${ch.toUpperCase()}) успешно привязан к ПАК WORK!`,
80
+ `• Лицензия: ${lic.licenseId}`,
81
+ `• Организация: ${lic.customer?.name || '—'}`,
82
+ `• Платформа: ${lic.hardware?.cpu || 'AMD Ryzen 495 8085'} (${lic.hardware?.ramGb || 192} ГБ RAM, SSD ${lic.hardware?.ssdTb || 4} ТБ)`,
83
+ `• Рабочие места: ${lic.totalSeats} (${lic.baseSeats} вечных + ${lic.addonSeats} годовых)`,
84
+ `• Уровень поддержки: ${lic.support.tierName} (время реакции ${lic.support.reactionLabel})`,
85
+ `• Остаток обращений: ${lic.support.ticketsRemaining} из ${lic.support.ticketsQuota}`,
86
+ ].join('\n');
87
+ await this.sendExternalMessage(ch, cid, reply);
88
+ return { action: 'bind', ok: true, licenseId: lic.licenseId, reply };
89
+ }
90
+ catch (err) {
91
+ const reply = `Ошибка привязки: ${err.message || err}`;
92
+ await this.sendExternalMessage(ch, cid, reply);
93
+ return { action: 'bind', ok: false, reply };
94
+ }
95
+ }
96
+
97
+ // Команда /status
98
+ if (/^\/(status|статус)$/i.test(cleanText)) {
99
+ const lic = this.store.getBoundLicenseForChat(ch, cid);
100
+ if (!lic) {
101
+ const reply = 'Чат ещё не привязан к лицензии ПАК. Отправьте /bind <НОМЕР_ЛИЦЕНЗИИ_ИЛИ_КОД>.';
102
+ await this.sendExternalMessage(ch, cid, reply);
103
+ return { action: 'status', ok: false, reply };
104
+ }
105
+ const reply = [
106
+ `Статус лицензии ПАК WORK: ${lic.licenseId}`,
107
+ `• Оборудование: ${lic.hardware?.cpu || 'AMD Ryzen 495 8085'} · ${lic.hardware?.ramGb || 192} ГБ RAM · SSD ${lic.hardware?.ssdTb || 4} ТБ`,
108
+ `• HWID: ${lic.hardware?.hwid || '—'}`,
109
+ `• Доступно одновременных сессий: ${lic.totalSeats} (${lic.baseSeats} вечных + ${lic.addonSeats} срочных годовых)`,
110
+ `• Уровень SLA поддержки: ${lic.support.tierName} (${lic.support.scheduleLabel})`,
111
+ `• Гарантированное время реакции: ${lic.support.reactionLabel}`,
112
+ `• Доступно обращений в этом году: ${lic.support.ticketsRemaining} из ${lic.support.ticketsQuota}`,
113
+ ].join('\n');
114
+ await this.sendExternalMessage(ch, cid, reply);
115
+ return { action: 'status', ok: true, license: lic, reply };
116
+ }
117
+
118
+ // Команда /buy <start|business|enterprise> [seats] [years]
119
+ const buyMatch = cleanText.match(/^\/(?:buy|купить)\s*(?:(start|business|enterprise))?\s*(\d+)?\s*(\d+)?/i);
120
+ if (buyMatch) {
121
+ const lic = this.store.getBoundLicenseForChat(ch, cid);
122
+ if (!lic) {
123
+ const reply = 'Сначала привяжите ваш ПАК командой /bind <НОМЕР_ЛИЦЕНЗИИ>, чтобы докупить рабочие места.';
124
+ await this.sendExternalMessage(ch, cid, reply);
125
+ return { action: 'buy', ok: false, reply };
126
+ }
127
+ const tierKey = (buyMatch[1] || 'business').toLowerCase();
128
+ const seats = Math.max(1, Number(buyMatch[2] || 1));
129
+ const years = Math.max(1, Math.min(5, Number(buyMatch[3] || 1)));
130
+ const tierSpec = SLA_TIERS[tierKey] || SLA_TIERS.business;
131
+
132
+ const order = this.store.createOrder({
133
+ licenseId: lic.licenseId,
134
+ tier: tierSpec.id,
135
+ addonSeats: seats,
136
+ years,
137
+ customer: lic.customer,
138
+ });
139
+ const payment = await this.yookassa.createPaymentForOrder(order, { originOverride });
140
+ const reply = [
141
+ `Сформирован счёт ЮKassa (${order.orderId}) для лицензии ${lic.licenseId}:`,
142
+ `• Тариф поддержки: ${tierSpec.name} (${tierSpec.pricePerUserYearRub.toLocaleString('ru-RU')} ₽/год за 1 место)`,
143
+ `• Доп. рабочих мест: ${seats} на ${years} г.`,
144
+ `• Сумма к оплате: ${order.amountRub.toLocaleString('ru-RU')} ₽`,
145
+ `• Ссылка на оплату ЮKassa: ${payment.confirmationUrl}`,
146
+ ].join('\n');
147
+ await this.sendExternalMessage(ch, cid, reply);
148
+ return { action: 'buy', ok: true, orderId: order.orderId, confirmationUrl: payment.confirmationUrl, reply };
149
+ }
150
+
151
+ // Обычное сообщение -> создание тикета техподдержки с контролем SLA и лимита обращений
152
+ const lic = this.store.getBoundLicenseForChat(ch, cid);
153
+ if (!lic) {
154
+ const reply = 'Для отправки обращения в техподдержку сначала привяжите ваш ПАК WORK командой:\n/bind <НОМЕР_ЛИЦЕНЗИИ_ИЛИ_КОД>';
155
+ await this.sendExternalMessage(ch, cid, reply);
156
+ return { action: 'unauthenticated', ok: false, reply };
157
+ }
158
+
159
+ try {
160
+ const { ticket, license: updatedLic } = this.store.createSupportTicket({
161
+ licenseId: lic.licenseId,
162
+ channel: ch,
163
+ chatId: cid,
164
+ userName,
165
+ text: cleanText,
166
+ });
167
+ const reply = [
168
+ `Обращение ${ticket.ticketId} зарегистрировано!`,
169
+ `• Тариф SLA: ${ticket.tierName} (гарантированное время реакции: ${ticket.reactionLabel})`,
170
+ `• Остаток обращений по вашей квоте: ${updatedLic.support.ticketsRemaining} из ${updatedLic.support.ticketsQuota}`,
171
+ 'Инженер поддержки ответит вам прямо в этот чат.',
172
+ ].join('\n');
173
+ await this.sendExternalMessage(ch, cid, reply);
174
+ return { action: 'ticket_created', ok: true, ticket, reply };
175
+ }
176
+ catch (err) {
177
+ const reply = `Обращение не создано: ${err.message || err}`;
178
+ await this.sendExternalMessage(ch, cid, reply);
179
+ return { action: 'ticket_quota_exceeded', ok: false, reply };
180
+ }
181
+ }
182
+
183
+ /**
184
+ * Разбор нативного Webhook от мессенджера MAX (https://botapi.max.ru).
185
+ */
186
+ async handleMaxWebhook(payload = {}, originOverride = '') {
187
+ const msg = payload.message || {};
188
+ const sender = msg.sender || payload.user || {};
189
+ const recipient = msg.recipient || {};
190
+ const chatId = String(recipient.chat_id || sender.user_id || payload.chat_id || 'max-chat');
191
+ const userName = String(sender.name || sender.username || 'Пользователь MAX');
192
+ const text = String(msg.body?.text ?? payload.text ?? '');
193
+ return this.handleIncomingMessage({ channel: 'max', chatId, userName, text, originOverride });
194
+ }
195
+
196
+ /**
197
+ * Разбор нативного Webhook от Telegram Bot API.
198
+ */
199
+ async handleTelegramWebhook(payload = {}, originOverride = '') {
200
+ const msg = payload.message || payload.edited_message || payload.callback_query?.message || {};
201
+ const from = msg.from || payload.callback_query?.from || {};
202
+ const chatId = String(msg.chat?.id || from.id || 'tg-chat');
203
+ const userName = [from.first_name, from.last_name].filter(Boolean).join(' ') || from.username || 'Пользователь Telegram';
204
+ const text = String(payload.callback_query?.data || msg.text || payload.text || '');
205
+ return this.handleIncomingMessage({ channel: 'telegram', chatId, userName, text, originOverride });
206
+ }
207
+
208
+ /**
209
+ * Разбор нативного Webhook от VK Callback API.
210
+ */
211
+ async handleVkWebhook(payload = {}, originOverride = '') {
212
+ if (payload.type === 'confirmation') {
213
+ return { isVkConfirmation: true, body: this.vkConfirmationCode };
214
+ }
215
+ const obj = payload.object?.message || payload.object || {};
216
+ const chatId = String(obj.peer_id || obj.from_id || payload.chat_id || 'vk-chat');
217
+ const userName = String(payload.user_name || `VK #${obj.from_id || 'user'}`);
218
+ const text = String(obj.text || payload.text || '');
219
+ const result = await this.handleIncomingMessage({ channel: 'vk', chatId, userName, text, originOverride });
220
+ return { isVkConfirmation: false, result, body: 'ok' };
221
+ }
222
+
223
+ /**
224
+ * Уведомить все привязанные чаты (MAX, Telegram, VK) об успешной оплате и продлении лицензии.
225
+ */
226
+ async notifyLicenseOrderPaid(order, licenseSummary) {
227
+ const bindings = this.store.getBindingsForLicense(licenseSummary.licenseId);
228
+ const text = [
229
+ `Оплата по заказу ${order.orderId} (${order.amountRub.toLocaleString('ru-RU')} ₽) успешно получена!`,
230
+ `• Лицензия ПАК: ${licenseSummary.licenseId}`,
231
+ `• Доступно одновременных рабочих мест: ${licenseSummary.totalSeats} (${licenseSummary.baseSeats} вечных + ${licenseSummary.addonSeats} годовых)`,
232
+ `• Уровень поддержки: ${licenseSummary.support.tierName} (реакция ${licenseSummary.support.reactionLabel})`,
233
+ `• Доступно обращений: ${licenseSummary.support.ticketsRemaining} из ${licenseSummary.support.ticketsQuota}`,
234
+ ].join('\n');
235
+ for (const b of bindings) {
236
+ await this.sendExternalMessage(b.channel, b.chatId, text);
237
+ }
238
+ }
239
+
240
+ /**
241
+ * Доставить ответ инженера поддержки в мессенджер клиента (MAX, Telegram, VK).
242
+ */
243
+ async deliverEngineerReply(ticket) {
244
+ const lastMsg = ticket.messages[ticket.messages.length - 1];
245
+ if (!lastMsg)
246
+ return;
247
+ const text = [
248
+ `Ответ по обращению ${ticket.ticketId} (${lastMsg.author}):`,
249
+ lastMsg.text,
250
+ ].join('\n\n');
251
+ await this.sendExternalMessage(ticket.channel, ticket.chatId, text);
252
+ }
253
+
254
+ /**
255
+ * Отправить сообщение во внешний мессенджер (MAX / Telegram / VK) и записать в журнал outboxLog.
256
+ */
257
+ async sendExternalMessage(channel, chatId, text) {
258
+ const entry = {
259
+ channel: String(channel).toLowerCase(),
260
+ chatId: String(chatId),
261
+ text: String(text),
262
+ time: Date.now(),
263
+ deliveredRemote: false,
264
+ };
265
+ this.outboxLog.push(entry);
266
+ if (this.outboxLog.length > 200)
267
+ this.outboxLog.shift();
268
+
269
+ if (typeof this.fetchImpl !== 'function')
270
+ return entry;
271
+
272
+ try {
273
+ if (entry.channel === 'telegram' && this.telegramBotSecret) {
274
+ const url = `https://api.telegram.org/bot${this.telegramBotSecret}/sendMessage`;
275
+ const res = await this.fetchImpl(url, {
276
+ method: 'POST',
277
+ headers: { 'Content-Type': 'application/json' },
278
+ body: JSON.stringify({ chat_id: entry.chatId, text: entry.text }),
279
+ });
280
+ entry.deliveredRemote = res.ok;
281
+ }
282
+ else if (entry.channel === 'max' && this.maxBotSecret) {
283
+ const url = `https://botapi.max.ru/messages?chat_id=${encodeURIComponent(entry.chatId)}`;
284
+ const res = await this.fetchImpl(url, {
285
+ method: 'POST',
286
+ headers: {
287
+ 'Authorization': this.maxBotSecret,
288
+ 'Content-Type': 'application/json',
289
+ },
290
+ body: JSON.stringify({ text: entry.text }),
291
+ });
292
+ entry.deliveredRemote = res.ok;
293
+ }
294
+ else if (entry.channel === 'vk' && this.vkBotSecret) {
295
+ const url = 'https://api.vk.com/method/messages.send';
296
+ const params = new URLSearchParams({
297
+ peer_id: entry.chatId,
298
+ random_id: String(Math.floor(Math.random() * 1e9)),
299
+ message: entry.text,
300
+ v: '5.199',
301
+ access_token: this.vkBotSecret,
302
+ });
303
+ const res = await this.fetchImpl(`${url}?${params.toString()}`, { method: 'POST' });
304
+ entry.deliveredRemote = res.ok;
305
+ }
306
+ }
307
+ catch { /* в локальном/тестовом режиме остаётся в outboxLog */ }
308
+
309
+ return entry;
310
+ }
311
+ }