660 lines
29 KiB
JavaScript
660 lines
29 KiB
JavaScript
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.');
|
|
});
|