import { randomUUID, timingSafeEqual } from 'node:crypto'; import { mkdirSync, readFileSync } from 'node:fs'; import { createServer } from 'node:http'; import { dirname, resolve } from 'node:path'; import { DatabaseSync } from 'node:sqlite'; import { fileURLToPath } from 'node:url'; import { categories, configureTriage, maskContactDetails, moderateChatMessage, spamRecommendation, topics } from './classify.mjs'; import { examples } from './examples.mjs'; const actors = { student: 'Пользователь', staff: 'Сотрудник' }; const queues = { students: 'Пользовательская', staff: 'Внутренняя', spam: 'Спам' }; const statuses = { new: 'Новая', reviewed: 'Проверена', dev: 'Передана разработчикам', closed: 'Закрыта' }; const maxTextLength = 4000; const maxDraftLength = 2500; const maxContextLength = 12_000; const uuidPattern = /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/iu; const port = Number(process.env.SUPPORT_PORT || 8787); if (!Number.isInteger(port) || port < 1 || port > 65535) { throw new Error('SUPPORT_PORT должен быть номером порта от 1 до 65535.'); } const triage = configureTriage(process.env); const siteToken = process.env.SUPPORT_SITE_TOKEN; const siteAuthorization = siteToken ? Buffer.from(`Bearer ${siteToken}`) : null; const staffToken = process.env.SUPPORT_STAFF_TOKEN; const staffAuthorization = staffToken ? Buffer.from(`Bearer ${staffToken}`) : null; const allowedHosts = [`127.0.0.1:${port}`, `localhost:${port}`]; const allowedOrigins = new Set(allowedHosts.map((host) => `http://${host}`)); if (process.env.SUPPORT_WEB_ORIGIN) { const configuredOrigin = process.env.SUPPORT_WEB_ORIGIN; const url = URL.parse(configuredOrigin); if (url?.protocol !== 'https:' || url.origin !== configuredOrigin) { throw new Error('SUPPORT_WEB_ORIGIN должен содержать точный HTTPS-адрес сайта без пути.'); } allowedOrigins.add(configuredOrigin); } const sourceColumn = "source TEXT NOT NULL DEFAULT 'legacy' CHECK (source IN ('legacy', 'manual', 'site'))"; const databasePath = process.env.SUPPORT_DB ? resolve(process.env.SUPPORT_DB) : fileURLToPath(new URL('./data/support.sqlite', import.meta.url)); mkdirSync(dirname(databasePath), { recursive: true }); const db = new DatabaseSync(databasePath); db.exec(` PRAGMA journal_mode = WAL; PRAGMA busy_timeout = 3000; PRAGMA secure_delete = ON; CREATE TABLE IF NOT EXISTS support_state ( id INTEGER PRIMARY KEY CHECK (id = 1), generation INTEGER NOT NULL, ticket_id_floor INTEGER NOT NULL ) STRICT; INSERT OR IGNORE INTO support_state (id, generation, ticket_id_floor) VALUES (1, 0, 0); CREATE TABLE IF NOT EXISTS tickets ( id INTEGER PRIMARY KEY, request_id TEXT NOT NULL UNIQUE, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, actor TEXT NOT NULL CHECK (actor IN ('student', 'staff')), text TEXT NOT NULL, category TEXT NOT NULL CHECK (category IN ('urgent', 'normal', 'spam')), topic TEXT NOT NULL CHECK (topic IN ('learning', 'file', 'access', 'bug', 'other', 'spam')), student_impact INTEGER NOT NULL CHECK (student_impact IN (0, 1)), needs_review INTEGER NOT NULL CHECK (needs_review IN (0, 1)), reason TEXT NOT NULL, draft TEXT NOT NULL, status TEXT NOT NULL CHECK (status IN ('new', 'reviewed', 'dev', 'closed')), mode TEXT NOT NULL CHECK (mode IN ('demo', 'llm')), provider TEXT, model TEXT, ${sourceColumn} ) STRICT; CREATE TABLE IF NOT EXISTS chat_sessions ( id TEXT PRIMARY KEY, actor TEXT NOT NULL CHECK (actor IN ('student', 'staff')), ticket_id INTEGER UNIQUE REFERENCES tickets (id) ) STRICT; CREATE TABLE IF NOT EXISTS chat_messages ( id INTEGER PRIMARY KEY, session_id TEXT NOT NULL REFERENCES chat_sessions (id), request_id TEXT, role TEXT NOT NULL CHECK (role IN ('customer', 'staff', 'moderator', 'operator')), text TEXT NOT NULL, created_at TEXT NOT NULL, accepted INTEGER NOT NULL CHECK (accepted IN (0, 1)), profanity INTEGER NOT NULL CHECK (profanity IN (0, 1)), UNIQUE (session_id, request_id) ) STRICT; CREATE INDEX IF NOT EXISTS chat_messages_session ON chat_messages (session_id, id); `); db.exec('BEGIN IMMEDIATE'); try { const ticketFields = db.prepare('PRAGMA table_info(tickets)').all(); if (!ticketFields.some((column) => column.name === 'source')) { db.exec(`ALTER TABLE tickets ADD COLUMN ${sourceColumn}`); } db.exec('COMMIT'); } catch (error) { db.exec('ROLLBACK'); throw error; } const columns = ` id, request_id AS requestId, created_at AS createdAt, updated_at AS updatedAt, source, CASE source WHEN 'site' THEN CASE actor WHEN 'staff' THEN 'Чат сотрудников' ELSE 'Чат пользователей' END WHEN 'manual' THEN 'Ручной ввод' ELSE 'Прежнее обращение' END AS channel, CASE WHEN source = 'site' AND request_id LIKE 'site:%' THEN substr(request_id, 6) END AS externalId, EXISTS (SELECT 1 FROM chat_sessions WHERE ticket_id = tickets.id) AS hasConversation, actor, text, category, CASE WHEN category = 'spam' THEN 'spam' WHEN topic = 'spam' THEN 'other' ELSE topic END AS topic, student_impact AS studentImpact, needs_review AS needsReview, reason, draft, status, mode, provider, model, CASE WHEN category = 'spam' THEN 'spam' WHEN actor = 'student' OR student_impact = 1 THEN 'students' ELSE 'staff' END AS queue `; const listTickets = db.prepare(` SELECT ${columns} FROM tickets ORDER BY CASE WHEN status = 'closed' THEN 1 ELSE 0 END, CASE queue WHEN 'students' THEN 0 WHEN 'staff' THEN 1 ELSE 2 END, CASE category WHEN 'urgent' THEN 0 WHEN 'normal' THEN 1 ELSE 2 END, id `); const findTicket = db.prepare(`SELECT ${columns} FROM tickets WHERE id = ?`); const findRequest = db.prepare(`SELECT ${columns} FROM tickets WHERE request_id = ?`); const insertTicket = db.prepare(` INSERT INTO tickets ( id, request_id, created_at, updated_at, actor, text, category, topic, student_impact, needs_review, reason, draft, status, mode, provider, model, source ) VALUES ( MAX((SELECT ticket_id_floor FROM support_state), COALESCE((SELECT MAX(id) FROM tickets), 0)) + 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'new', ?, ?, ?, ? ) ON CONFLICT(request_id) DO NOTHING `); const updateTicket = db.prepare(` UPDATE tickets SET updated_at = ?, category = ?, student_impact = ?, draft = ?, status = ? WHERE id = ? `); const updateChatTicket = db.prepare(` UPDATE tickets SET updated_at = ?, text = ?, category = ?, topic = ?, student_impact = ?, needs_review = ?, reason = ?, draft = ?, status = 'new', mode = ?, provider = ?, model = ? WHERE id = ? `); const createSession = db.prepare('INSERT INTO chat_sessions (id, actor) VALUES (?, ?)'); const findSession = db.prepare('SELECT id, actor, ticket_id AS ticketId FROM chat_sessions WHERE id = ?'); const findTicketSession = db.prepare('SELECT id, actor, ticket_id AS ticketId FROM chat_sessions WHERE ticket_id = ?'); const linkTicket = db.prepare('UPDATE chat_sessions SET ticket_id = ? WHERE id = ?'); const listMessages = db.prepare(` SELECT id, role, text, created_at AS createdAt FROM chat_messages WHERE session_id = ? ORDER BY id `); const listAcceptedMessages = db.prepare(` SELECT text FROM chat_messages WHERE session_id = ? AND accepted = 1 ORDER BY id DESC LIMIT 100 `); const countProfanity = db.prepare('SELECT count(*) AS count FROM chat_messages WHERE session_id = ? AND profanity = 1'); const findMessage = db.prepare('SELECT role, text FROM chat_messages WHERE session_id = ? AND request_id = ?'); const lastAuthorMessage = db.prepare(` SELECT id FROM chat_messages WHERE session_id = ? AND role IN ('customer', 'staff') ORDER BY id DESC LIMIT 1 `); const insertMessage = db.prepare(` INSERT INTO chat_messages (session_id, request_id, role, text, created_at, accepted, profanity) VALUES (?, ?, ?, ?, ?, ?, ?) `); const readStoreGeneration = db.prepare('SELECT generation FROM support_state'); const advanceStoreGeneration = db.prepare(` UPDATE support_state SET generation = generation + 1, ticket_id_floor = MAX(ticket_id_floor, COALESCE((SELECT MAX(id) FROM tickets), 0)) `); const deleteMessages = db.prepare('DELETE FROM chat_messages'); const deleteSessions = db.prepare('DELETE FROM chat_sessions'); const deleteTickets = db.prepare('DELETE FROM tickets'); const hasIntakeCheckpoints = db.prepare(` SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name = 'intake_checkpoints' `).get(); const deleteIntakeCheckpoints = hasIntakeCheckpoints ? db.prepare('DELETE FROM intake_checkpoints') : null; const assets = new Map([ ['/', ['index.html', 'text/html']], ['/app.js', ['app.js', 'text/javascript']], ['/styles.css', ['styles.css', 'text/css']], ['/site-chat', ['site-chat.html', 'text/html']], ['/staff-chat', ['site-chat.html', 'text/html']], ['/site-chat.js', ['site-chat.js', 'text/javascript']], ]); for (const [path, [file, type]] of assets) { assets.set(path, [readFileSync(new URL(`./public/${file}`, import.meta.url)), type]); } class HttpError extends Error { constructor(status, message) { super(message); this.status = status; } } function send(response, status, type, body, headers = {}) { response.writeHead(status, { 'Content-Type': `${type}; charset=utf-8`, ...headers }); response.end(body); } function sendJson(response, status, body) { send(response, status, 'application/json', JSON.stringify(body)); } async function readJson(request) { if (request.headers['content-type']?.split(';')[0].trim().toLowerCase() !== 'application/json') { throw new HttpError(415, 'Ожидается JSON.'); } let bytes = 0; const chunks = []; for await (const chunk of request) { bytes += chunk.length; if (bytes <= 32_768) chunks.push(chunk); } if (bytes > 32_768) throw new HttpError(413, 'Запрос слишком большой.'); let body; try { body = JSON.parse(Buffer.concat(chunks).toString('utf8')); } catch { throw new HttpError(400, 'Не удалось прочитать JSON.'); } if (!body || typeof body !== 'object' || Array.isArray(body)) { throw new HttpError(400, 'Ожидается объект JSON.'); } return body; } function ticketJson(row) { const source = row.source === 'site' || row.source === 'manual' ? row.source : 'legacy'; return { ...row, source, studentImpact: Boolean(row.studentImpact), needsReview: Boolean(row.needsReview), hasConversation: Boolean(row.hasConversation) }; } function reuseRequest(row, actor, text) { if (row.actor !== actor || row.text !== text) { throw new HttpError(409, 'Идентификатор обработки уже использован для другого обращения.'); } return ticketJson(row); } const pendingRequests = new Map(); async function classifyTicket(actor, text, generation, latestMessage) { try { return await triage.classify(actor, text, latestMessage); } catch (error) { ensureCurrentGeneration(generation); throw new HttpError(502, error.message); } } function spamResult(reason) { return { category: 'spam', topic: 'spam', studentImpact: false, needsReview: false, reason: reason || 'Сообщение отнесено к спаму.', draft: spamRecommendation, }; } function ticketFields(result) { return [result.category, result.topic, Number(result.studentImpact), Number(result.needsReview), result.reason, result.draft]; } function validatedText(value, limit = maxTextLength) { if (typeof value !== 'string' || !value.trim() || value.trim().length > limit) { throw new HttpError(400, `Введите текст от 1 до ${limit} символов.`); } return value.trim(); } async function createTicket({ actor, requestId, text: incomingText, source }, generation) { ensureCurrentGeneration(generation); if (typeof actor !== 'string' || !Object.hasOwn(actors, actor)) { throw new HttpError(400, 'Укажите, кто обратился.'); } const rawText = validatedText(incomingText); const text = maskContactDetails(rawText); const moderation = source === 'site' ? moderateChatMessage({ actor, text: rawText }) : null; const reply = moderation?.reply ?? null; const stored = findRequest.get(requestId); if (stored) return { ticket: reuseRequest(stored, actor, text), created: false, reply }; const operationKey = `${generation}:${requestId}`; const pending = pendingRequests.get(operationKey); if (pending) { const { row } = await pending; ensureCurrentGeneration(generation); return { ticket: reuseRequest(row, actor, text), created: false, reply }; } const processTicket = async () => { const result = moderation?.block ? spamResult(moderation.reason) : await classifyTicket(actor, text, generation); const now = new Date().toISOString(); return writeCurrentGeneration(generation, () => { const insertion = insertTicket.run( requestId, now, now, actor, text, ...ticketFields(result), triage.mode, triage.provider, triage.model, source, ); return { row: findRequest.get(requestId), created: insertion.changes === 1 }; }); }; const processing = processTicket(); pendingRequests.set(operationKey, processing); try { const { row, created } = await processing; ensureCurrentGeneration(generation); return { ticket: reuseRequest(row, actor, text), created, reply }; } finally { pendingRequests.delete(operationKey); } } function createManualTicket({ actor, requestId = randomUUID(), text }, generation) { if (typeof requestId !== 'string' || !uuidPattern.test(requestId)) { throw new HttpError(400, 'Неверный идентификатор обработки.'); } return createTicket({ actor, requestId, text, source: 'manual' }, generation); } function createImportedTicket(body, generation) { const { externalId, text } = body; if (Object.keys(body).some((key) => !['externalId', 'text'].includes(key))) { throw new HttpError(400, 'Передайте только externalId и text.'); } if (typeof externalId !== 'string' || !/^[a-z0-9][a-z0-9._:-]{0,127}$/iu.test(externalId)) { throw new HttpError(400, 'Неверный внешний идентификатор обращения.'); } return createTicket({ actor: 'student', requestId: `site:${externalId}`, text, source: 'site' }, generation); } function checkAuthorization(request, authorization, missingMessage) { if (!authorization) throw new HttpError(503, missingMessage); const provided = Buffer.from(request.headers.authorization || ''); if (provided.length !== authorization.length || !timingSafeEqual(provided, authorization)) { throw new HttpError(401, 'Неверный ключ доступа.'); } } function sessionForActor(id, actor) { const session = uuidPattern.test(id) && findSession.get(id); if (!session || session.actor !== actor) throw new HttpError(404, 'Диалог не найден.'); return session; } function sessionForTicket(ticketId) { const session = findTicketSession.get(ticketId); if (!session) throw new HttpError(404, 'У обращения нет диалога.'); return session; } function sessionJson(session) { return { id: session.id, ticketId: session.ticketId, messages: listMessages.all(session.id) }; } function operatorMessages(session) { return { ticketId: session.ticketId, messages: listMessages.all(session.id) }; } function validatedMessage({ requestId, text, ...extra }, limit = maxTextLength) { if (typeof requestId !== 'string' || !uuidPattern.test(requestId) || Object.keys(extra).length) { throw new HttpError(400, 'Передайте текст и корректный идентификатор сообщения.'); } return { requestId, text: validatedText(text, limit) }; } function previousMessage(sessionId, requestId, role, text) { const existing = findMessage.get(sessionId, requestId); if (existing && (existing.role !== role || existing.text !== text)) { throw new HttpError(409, 'Идентификатор уже использован для другого сообщения.'); } return existing; } function writeTransaction(operation) { db.exec('BEGIN IMMEDIATE'); try { const result = operation(); db.exec('COMMIT'); return result; } catch (error) { db.exec('ROLLBACK'); throw error; } } function ensureCurrentGeneration(generation) { if (generation !== readStoreGeneration.get().generation) { throw new HttpError(409, 'База обращений была очищена. Начните заново.'); } } function writeCurrentGeneration(generation, operation) { return writeTransaction(() => { ensureCurrentGeneration(generation); return operation(); }); } function clearStore(generation) { const deleted = writeCurrentGeneration(generation, () => { advanceStoreGeneration.run(); const deletedMessages = deleteMessages.run().changes; const deletedSessions = deleteSessions.run().changes; const deletedTickets = deleteTickets.run().changes; deleteIntakeCheckpoints?.run(); return { deletedTickets, deletedMessages, deletedSessions }; }); try { db.exec('PRAGMA wal_checkpoint(TRUNCATE)'); } catch { // The deletion is already committed if a concurrent connection prevents this checkpoint. } return deleted; } function recentChatContext(history, text) { const context = [text]; let length = text.length; for (let index = history.length - 1; index >= 0; index -= 1) { length += history[index].length + 1; if (length > maxContextLength) break; context.unshift(history[index]); } return context.join('\n'); } const pendingChats = new Map(); async function appendAuthorMessage(id, actor, body, generation) { const { requestId, text } = validatedMessage(body); const lastOperation = pendingChats.get(id); const operation = (lastOperation ?? Promise.resolve()).catch(() => {}).then(() => saveAuthorMessage(id, actor, requestId, text, generation)); pendingChats.set(id, operation); try { return await operation; } finally { if (pendingChats.get(id) === operation) pendingChats.delete(id); } } async function saveAuthorMessage(id, actor, requestId, rawText, generation) { const role = actor === 'staff' ? 'staff' : 'customer'; const text = maskContactDetails(rawText); // Serialize local requests and verify the latest stored turn before committing. for (let attempt = 0; attempt < 3; attempt += 1) { ensureCurrentGeneration(generation); const session = sessionForActor(id, actor); if (previousMessage(id, requestId, role, text)) return { session, created: false }; const previousTurn = lastAuthorMessage.get(id)?.id; const history = listAcceptedMessages.all(id).reverse().map((row) => row.text); const moderation = moderateChatMessage({ actor, text: rawText, history, profanityCount: countProfanity.get(id).count }); const context = recentChatContext(history, text); const result = moderation.block ? spamResult(moderation.reason) : await classifyTicket(actor, context, generation, text); const spam = result.category === 'spam'; const committed = writeCurrentGeneration(generation, () => { if (lastAuthorMessage.get(id)?.id !== previousTurn) return null; const now = new Date().toISOString(); insertMessage.run(id, requestId, role, text, now, Number(!spam), Number(moderation.profanity)); if (moderation.reply) insertMessage.run(id, null, 'moderator', moderation.reply, now, 0, 0); if (!spam && session.ticketId !== null) { updateChatTicket.run(now, context, ...ticketFields(result), triage.mode, triage.provider, triage.model, session.ticketId); } else { const { lastInsertRowid } = insertTicket.run( randomUUID(), now, now, actor, spam ? text : context, ...ticketFields(result), triage.mode, triage.provider, triage.model, 'site', ); if (!spam) linkTicket.run(lastInsertRowid, id); } return findSession.get(id); }); if (committed) return { session: committed, created: true }; } throw new HttpError(409, 'В диалоге появились новые сообщения. Повторите отправку.'); } function appendOperatorMessage(session, body, generation) { const { requestId, text: rawText } = validatedMessage(body, maxDraftLength); const text = maskContactDetails(rawText); const now = new Date().toISOString(); return writeCurrentGeneration(generation, () => { if (previousMessage(session.id, requestId, 'operator', text)) return false; insertMessage.run(session.id, requestId, 'operator', text, now, 0, 0); return true; }); } function editTicket(id, body) { const current = findTicket.get(id); if (!current) throw new HttpError(404, 'Обращение не найдено.'); const { category = current.category, draft = current.draft, studentImpact = Boolean(current.studentImpact), status = current.status, } = body; if (Object.keys(body).some((key) => !['category', 'draft', 'studentImpact', 'status'].includes(key))) { throw new HttpError(400, 'В правке есть неизвестное поле.'); } if (typeof category !== 'string' || !Object.hasOwn(categories, category) || typeof status !== 'string' || !Object.hasOwn(statuses, status) || typeof draft !== 'string' || !draft.trim() || draft.length > maxDraftLength || typeof studentImpact !== 'boolean') { throw new HttpError(400, 'Проверьте категорию, статус, черновик и признак влияния на учеников.'); } const impact = current.actor === 'staff' && category !== 'spam' && studentImpact; const editedDraft = draft.trim(); const savedDraft = category === 'spam' && current.category !== 'spam' && editedDraft === current.draft ? spamRecommendation : maskContactDetails(editedDraft); updateTicket.run(new Date().toISOString(), category, Number(impact), savedDraft, status, id); return ticketJson(findTicket.get(id)); } function csvCell(value) { const text = String(value ?? ''); const safe = /^[\s\uFEFF]*[=+\-@]/u.test(text) ? `'${text}` : text; return `"${safe.replaceAll('"', '""')}"`; } function ticketsCsv() { const header = [ 'Номер', 'Создано (UTC)', 'Обновлено (UTC)', 'Канал', 'ID в источнике', 'Автор', 'Текст', 'Категория', 'Тема', 'Очередь', 'Сбой затрагивает учеников', 'Пометка для проверки', 'Основание первичной оценки', 'Черновик', 'Статус', 'Режим', 'Провайдер', 'Модель', ]; const rows = listTickets.all().map((row) => [ row.id, row.createdAt, row.updatedAt, row.channel, row.externalId, actors[row.actor], row.text, categories[row.category], topics[row.topic], queues[row.queue], row.studentImpact ? 'Да' : 'Нет', row.needsReview ? 'Да' : 'Нет', row.reason, row.draft, statuses[row.status], row.mode === 'demo' ? 'Демо' : 'LLM', row.provider, row.model, ]); return `\uFEFF${[header, ...rows].map((row) => row.map(csvCell).join(',')).join('\r\n')}\r\n`; } async function route(request, response) { if (!allowedHosts.includes(request.headers.host)) throw new HttpError(403, 'Прототип доступен локально.'); if (request.headers.origin && !allowedOrigins.has(request.headers.origin)) { throw new HttpError(403, 'Запрос с другого сайта отклонён.'); } const { pathname } = new URL(request.url, 'http://localhost'); const writesToStore = request.method === 'POST' || request.method === 'PATCH'; const generation = writesToStore ? readStoreGeneration.get().generation : null; if (request.method === 'GET') { const asset = assets.get(pathname); if (asset) return send(response, 200, asset[1], asset[0]); if (pathname === '/api/config') { const { mode, provider } = triage; return sendJson(response, 200, { mode, provider, actors, categories, topics, queues, statuses, maxTextLength }); } if (pathname === '/api/examples') return sendJson(response, 200, { examples }); if (pathname === '/api/tickets') return sendJson(response, 200, { tickets: listTickets.all().map(ticketJson) }); if (pathname === '/export.csv') { return send(response, 200, 'text/csv', ticketsCsv(), { 'Content-Disposition': 'attachment; filename="support-tickets.csv"' }); } } const conversationPath = /^\/api\/chat\/(users|staff)\/sessions(?:\/([^/]+)(\/messages)?)?$/u.exec(pathname); if (conversationPath) { const [, channel, id, messages] = conversationPath; const actor = channel === 'staff' ? 'staff' : 'student'; if (request.method === 'POST' && !id) { if (actor === 'staff' && (triage.mode !== 'demo' || staffAuthorization)) { checkAuthorization(request, staffAuthorization, 'Вход сотрудников ещё не настроен.'); } if (Object.keys(await readJson(request)).length) throw new HttpError(400, 'Ожидается пустой объект.'); const sessionId = randomUUID(); writeCurrentGeneration(generation, () => createSession.run(sessionId, actor)); return sendJson(response, 201, { id: sessionId, ticketId: null, messages: [] }); } if (request.method === 'GET' && id && !messages) { return sendJson(response, 200, sessionJson(sessionForActor(id, actor))); } if (request.method === 'POST' && messages) { const { session, created } = await appendAuthorMessage(id, actor, await readJson(request), generation); return sendJson(response, created ? 201 : 200, sessionJson(session)); } } if (request.method === 'POST' && pathname === '/api/tickets/clear') { if (Object.keys(await readJson(request)).length) throw new HttpError(400, 'Ожидается пустой объект.'); return sendJson(response, 200, clearStore(generation)); } if (request.method === 'POST' && pathname === '/api/tickets') { const { ticket, created } = await createManualTicket(await readJson(request), generation); return sendJson(response, created ? 201 : 200, { ticket }); } if (request.method === 'POST' && (pathname === '/api/intake/site' || pathname === '/api/demo/site')) { if (pathname === '/api/intake/site') { checkAuthorization(request, siteAuthorization, 'Приём от сервера сайта ещё не настроен.'); } else if (triage.mode !== 'demo') { throw new HttpError(404, 'Страница не найдена.'); } const { ticket, created, reply } = await createImportedTicket(await readJson(request), generation); return sendJson(response, created ? 201 : 200, reply ? { id: ticket.id, reply } : { id: ticket.id }); } const ticketMessagesPath = /^\/api\/tickets\/(\d+)\/messages$/u.exec(pathname); if (ticketMessagesPath && (request.method === 'GET' || request.method === 'POST')) { const ticketId = Number(ticketMessagesPath[1]); if (!Number.isSafeInteger(ticketId)) throw new HttpError(404, 'У обращения нет диалога.'); const session = sessionForTicket(ticketId); if (request.method === 'GET') return sendJson(response, 200, operatorMessages(session)); const created = appendOperatorMessage(session, await readJson(request), generation); return sendJson(response, created ? 201 : 200, operatorMessages(session)); } const editPath = /^\/api\/tickets\/(\d+)$/u.exec(pathname); if (request.method === 'PATCH' && editPath) { const id = Number(editPath[1]); if (!Number.isSafeInteger(id)) throw new HttpError(404, 'Обращение не найдено.'); const body = await readJson(request); const ticket = writeCurrentGeneration(generation, () => editTicket(id, body)); return sendJson(response, 200, { ticket }); } throw new HttpError(404, 'Страница не найдена.'); } const server = createServer({ requestTimeout: 30_000 }, async (request, response) => { response.setHeader('Cache-Control', 'no-store'); response.setHeader('X-Content-Type-Options', 'nosniff'); response.setHeader('Referrer-Policy', 'no-referrer'); response.setHeader('Content-Security-Policy', "default-src 'self'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'"); try { await route(request, response); } catch (error) { if (error instanceof HttpError) { sendJson(response, error.status, { error: error.message }); return; } console.error('Внутренняя ошибка обработки запроса:', error.code || error.name); sendJson(response, 500, { error: 'Не удалось обработать запрос. Проверьте, доступна ли локальная база.' }); } }); const address = `http://127.0.0.1:${port}`; server.once('error', (error) => { process.exitCode = 1; db.close(); if (error.code === 'EADDRINUSE') { console.error(`Порт ${port} уже занят. Если прототип запущен, откройте ${address} в браузере.`); return; } console.error(`Не удалось запустить сервер на ${address} (${error.code || error.name}).`); }); server.listen(port, '127.0.0.1', () => { const label = triage.mode === 'demo' ? 'Демо по правилам' : `LLM · ${triage.provider}`; console.log(`Техподдержка ЕГЭ · ${label}`); console.log(`Панель оператора — ${address}`); console.log(`Чат пользователей — ${address}/site-chat`); console.log(`Чат сотрудников — ${address}/staff-chat`); console.log('Оставьте это окно открытым. Чтобы остановить сервер, нажмите Ctrl+C.'); });