// AI Assistant Monitor Worker v4.1.12 + encrypted stage backups // Обязательные bindings/secrets: // DB — D1 database binding // CONFIG_KV — тот же KV namespace, где лежат ключи // legend и catalog; доступ к ним выдаётся только // активированным установкам по персональному iat // DATA_ENCRYPTION_KEY — 32 случайных байта: 64 hex-символа или base64url // ADMIN_TOKEN — только для первоначального создания admin // ANTHROPIC_API_KEY — секретный ключ Anthropic; клиентским скриптам // никогда не передаётся // GROQ_API_KEY — секретный ключ Groq для расшифровки голосовых; // аудио и текст расшифровки в D1 не сохраняются // Временная совместимость: // INGEST_TOKEN — старые скрипты могут слать только телеметрию; // решения всегда требуют персональный iat-токен. // Рекомендуемые Rate Limiting bindings: // API_RATE_LIMITER, AI_RATE_LIMITER, ENROLL_RATE_LIMITER, // LOGIN_RATE_LIMITER // Рекомендуемый Cron Trigger: один раз в сутки, например 0 3 * * *. const WORKER_VERSION = '4.1.12'; const ONLINE_WINDOW_MS = 5 * 60 * 1000; // Если heartbeat продолжает приходить, но вкладка Dolphin скрыта дольше // этого порога, CRM показывает отдельное состояние «в фоне». const BACKGROUND_WARNING_MS = 2 * 60 * 1000; // Физически удаляем архив значительно позже. Это защищает от потери // телеметрии, если компьютер менеджера был выключен несколько дней. // Карточка менеджера при этом не удаляется: она хранится в manager_stages. const INSTALLATION_RETENTION_MS = 30 * 24 * 60 * 60 * 1000; // Клиентский снимок приходит минимум раз в 5 минут. Если heartbeat живой, // а строки клиентов отстали от него больше чем на 15 минут, такой снимок // считается сломанным и не участвует в рабочих счётчиках. const CLIENT_SNAPSHOT_STALE_GRACE_MS = 15 * 60 * 1000; const MAX_BODY_BYTES = 512 * 1024; const MAX_AI_BODY_BYTES = 30 * 1024 * 1024; const MAX_OKZ_AI_BODY_BYTES = 128 * 1024; const MAX_AUDIO_BODY_BYTES = 24 * 1024 * 1024; const MAX_FORM_BYTES = 32 * 1024; const MAX_CLIENTS_PER_REQUEST = 500; const MAX_EVENTS_PER_REQUEST = 200; // Удаление отсутствующих клиентов разрешено только полному снимку нового // формата. Старые и частичные выгрузки работают как безопасный upsert. const CLIENT_SNAPSHOT_SCHEMA_VERSION = 3; const MAX_CLOCK_AHEAD_MS = 5 * 60 * 1000; const DECISION_TTL_MS = 60 * 60 * 1000; const DECISION_EXECUTION_TTL_MS = 60 * 60 * 1000; const DECISION_CONTEXT_LIMIT = 8; const DECISION_RETENTION_MS = 14 * 24 * 60 * 60 * 1000; const DECISION_SENSITIVE_RETENTION_MS = 72 * 60 * 60 * 1000; const DECISION_LEASE_MS = 5 * 60 * 1000; const DECISION_MAX_EXECUTION_ATTEMPTS = 3; const DECISION_RETRY_DELAYS_MS = [30 * 1000, 2 * 60 * 1000, 5 * 60 * 1000]; const ENROLLMENT_TTL_MS = 15 * 60 * 1000; const INSTALLATION_TOKEN_PREFIX = 'iat'; const ACTIVATION_CODE_PREFIX = 'act'; const DATA_ENCRYPTION_KEY_VERSION = 1; const CURRENT_SCHEMA_VERSION = 10; const MAX_BACKUP_RECORD_BYTES = 256 * 1024; // Keep a stage's memory cutover and credential reassignment in one D1 batch. // Larger archives must be handled by an offline migration before stage merge. const MAX_MEMORY_MERGE_STATEMENTS = 32; const MAX_MEMORY_MERGE_MESSAGES = 1000; const MAX_MEMORY_MERGE_JSON_BYTES = 1024 * 1024; const AI_DEFAULT_MODEL = 'claude-sonnet-5'; const AI_ALLOWED_MODELS = new Set([ 'claude-sonnet-5', ]); const AI_TASK_VALUES = new Set(['dialogue', 'dossier', 'vision']); const AI_USAGE_RETENTION_DAYS = 90; const AI_DEFAULT_INSTALLATION_DAILY_LIMIT_USD = 250; const AI_DEFAULT_GLOBAL_DAILY_LIMIT_USD = 350; // Общий лимит для одного сетевого отпечатка OKZ. Его можно переопределить // переменной Worker OKZ_AI_DAILY_LIMIT_USD без передачи ключей в браузер. const AI_DEFAULT_OKZ_DAILY_LIMIT_USD = 10; const DECISION_TYPE_VALUES = new Set(['general', 'call']); const DECISION_STATUS_VALUES = new Set([ 'pending', 'resolved', 'executed', 'failed', 'expired', 'cancelled', ]); const GENERAL_DECISION_ACTIONS = new Set([ 'send_recommendation', 'ai_instruction', 'custom_text', 'manager_manual', 'pause', ]); const CALL_DECISION_ACTIONS = new Set([ 'confirm_time', 'plus_2h', 'custom_time', 'decline', 'manager_manual', ]); const STATUS_VALUES = new Set([ 'unknown', 'active', 'waiting_ai', 'waiting_client', 'call', 'human', 'paused', 'error', ]); const LEVEL_VALUES = new Set([ 'debug', 'info', 'warn', 'error', ]); export default { async fetch(request, env) { const requestId = crypto.randomUUID(); try { if (!env.DB) { return jsonResponse( { ok: false, error: 'DB_NOT_BOUND', request_id: requestId }, 503 ); } const url = new URL(request.url); const path = normalizePath(url.pathname); if (request.method === 'OPTIONS') { return new Response(null, { status: 204, headers: corsHeaders(), }); } if (path === '/favicon.ico') { return new Response(null, { status: 204 }); } await ensureServerSchema(env); if (request.method === 'GET' && path === '/') { return jsonResponse({ ok: true, service: 'AI Assistant Monitor', version: WORKER_VERSION, schema_version: CURRENT_SCHEMA_VERSION, server_time: Date.now(), endpoints: [ 'POST /api/v1/heartbeat', 'POST /api/v1/clients', 'POST /api/v1/events', 'POST /api/v1/snapshot', 'POST /api/v1/max/backup/save', 'POST /api/v1/max/backup/load', 'POST /api/v1/telegram/backup/save', 'POST /api/v1/telegram/backup/load', 'POST /api/v1/whatsapp/backup/save', 'POST /api/v1/whatsapp/backup/load', 'POST /api/v1/decisions/request', 'POST /api/v1/decisions/poll', 'POST /api/v1/decisions/lease/renew', 'POST /api/v1/decisions/prepared', 'POST /api/v1/decisions/ack', 'POST /api/v1/ai/messages', 'POST /api/v1/ai/okz/messages', 'POST /api/v1/audio/transcriptions', 'GET /api/v1/config/legend', 'GET /api/v1/config/catalog', 'POST /api/v1/enroll', 'GET /admin', ], }); } if (request.method === 'GET' && path === '/health') { const check = await env.DB.prepare( 'SELECT 1 AS healthy' ).first(); return jsonResponse({ ok: Boolean(check?.healthy), database: Boolean(check?.healthy), version: WORKER_VERSION, schema_version: CURRENT_SCHEMA_VERSION, server_time: Date.now(), }); } // ---------- Легенда и каталог для активированных установок ---------- // Клиент получает их по тому же персональному iat-токену, что и // heartbeat/AI Gateway. Отдельный Cloudflare ACCESS_TOKEN менеджеру // больше не нужен и в браузер не передаётся. const installationConfigRoutes = new Map([ ['/api/v1/config/legend', 'legend'], ['/api/v1/config/catalog', 'catalog'], ]); if (installationConfigRoutes.has(path)) { if (request.method !== 'GET') { return jsonResponse( { ok: false, error: 'METHOD_NOT_ALLOWED' }, 405, true ); } const installationAuth = await authenticateInstallation( request, env ); if (!installationAuth) { return jsonResponse( { ok: false, error: 'UNAUTHORIZED' }, 401, true ); } if (!env.CONFIG_KV || typeof env.CONFIG_KV.get !== 'function') { return jsonResponse( { ok: false, error: 'CONFIG_KV_NOT_BOUND' }, 503, true ); } const configKey = installationConfigRoutes.get(path); const configText = await env.CONFIG_KV.get(configKey); if ( typeof configText !== 'string' || !configText.trim() ) { return jsonResponse( { ok: false, error: 'CONFIG_NOT_FOUND' }, 404, true ); } return new Response(configText, { status: 200, headers: { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'private, no-store', ...corsHeaders(), }, }); } // Страница установки загрузчика: открывается кнопкой «Обновить» // в помощнике. Адрес задаёт администратор ключом loader_url_<платформа>, // чтобы ключ доступа не хранился в скриптах у менеджеров. if (request.method === 'GET' && path.startsWith('/loader/')) { const platform = path.slice('/loader/'.length); if (!VERSION_PLATFORMS.includes(platform)) { return new Response('Неизвестная платформа', { status: 404, headers: { 'Content-Type': 'text/plain; charset=utf-8' }, }); } const row = await env.DB.prepare( 'SELECT value FROM app_settings WHERE key = ?' ).bind('loader_url_' + platform).first().catch(() => null); const target = String(row?.value || '').trim(); if (!target) { return new Response( 'Ссылка на загрузчик не настроена. Откройте значок Tampermonkey и нажмите «Проверить обновления».', { status: 404, headers: { 'Content-Type': 'text/plain; charset=utf-8' } } ); } return Response.redirect(target, 302); } // ---------- Вход и сессии ---------- if (request.method === 'POST' && path === '/admin/login') { return await handleUserLogin(request, env); } if (request.method === 'POST' && path === '/admin/logout') { return await handleUserLogout(request, env); } if (request.method === 'POST' && path === '/api/v1/enroll') { return await handleInstallationEnrollment(request, env); } if (path.startsWith('/admin') || path.startsWith('/api/admin/')) { await ensureAuthSchema(env); const sessionUser = await getSessionUser(request, env); if (!sessionUser) { return path.startsWith('/api/admin/') ? jsonResponse({ ok: false, error: 'UNAUTHORIZED' }, 401) : htmlResponse(loginPageHtml(), 200); } // Пока временный пароль не сменён — доступна только эта страница if ( sessionUser.mustChangePassword && path !== '/admin/password' ) { return path.startsWith('/api/admin/') ? jsonResponse({ ok: false, error: 'PASSWORD_CHANGE_REQUIRED' }, 403) : new Response(null, { status: 303, headers: { Location: '/admin/password', ...securityHeaders() }, }); } if (path === '/admin/password') { if (request.method === 'POST') { return await handlePasswordChange(request, env); } return htmlResponse(passwordPageHtml(sessionUser), 200); } // ---------- Управление пользователями: только admin ---------- if ( path.startsWith('/admin/users') || path === '/admin/audit' || path.startsWith('/admin/installations/security') ) { if (!canManageUsers(sessionUser)) { return htmlResponse( '

' + 'Раздел доступен только администратору. Назад

', 403 ); } if (path === '/admin/audit') { return await handleAuditPage(request, env, sessionUser); } if (path.startsWith('/admin/installations/security')) { return await handleInstallationSecurityPage( request, env, sessionUser ); } if (request.method === 'POST' && path === '/admin/users/create') { return await handleUserCreate(request, env, sessionUser); } if (request.method === 'POST' && path === '/admin/users/update') { return await handleUserUpdate(request, env, sessionUser); } return await handleUsersPage(request, env, sessionUser); } // Стоп-кран: доступен только администратору if (path === '/admin/global-stop' && request.method === 'POST') { if (!canManageUsers(sessionUser)) { return jsonResponse({ ok: false, error: 'FORBIDDEN' }, 403, true); } if (!isSameOriginRequest(request)) { return jsonResponse({ ok: false, error: 'BAD_ORIGIN' }, 403, true); } const body = await readJsonBody(request); const stopped = body?.stopped === true; await writeGlobalStop(env, stopped, sessionUser); await writeAudit( env, sessionUser, stopped ? 'global_stop_on' : 'global_stop_off', '', request ); const state = await readGlobalStop(env); return jsonResponse({ ok: true, global_stop: state }, 200, true); } // Удаление устаревших подключений одного менеджера. // При переустановке загрузчика скрипт получает новый installation_id, // а прежняя запись остаётся в базе и дублирует платформу в карточке. if (path === '/admin/installations/forget' && request.method === 'POST') { if (!canManageUsers(sessionUser)) { return jsonResponse({ ok: false, error: 'FORBIDDEN' }, 403, true); } if (!isSameOriginRequest(request)) { return jsonResponse({ ok: false, error: 'BAD_ORIGIN' }, 403, true); } const body = await readJsonBody(request); const requestedIds = Array.isArray(body?.installation_ids) ? body.installation_ids.map((item) => cleanText(item, 160)).filter(Boolean) : []; // Один административный запрос не должен раздуваться до сотен // D1-операций. Для обычной карточки здесь 1-3 старых установки. const ids = [...new Set(requestedIds)].slice(0, 8); if (!ids.length) { return jsonResponse({ ok: false, error: 'IDS_REQUIRED' }, 400, true); } let removed = 0; let revoked = 0; const now = Date.now(); for (const installationId of ids) { // Простое удаление строки installations было небезопасным: // персональный iat оставался active и старый скрипт мог снова // создать подключение следующим heartbeat. Сначала отзываем токен. const credentialResult = await env.DB.prepare(` UPDATE installation_credentials SET status = 'revoked', revoked_at = ?, updated_at = ? WHERE installation_id = ? AND status <> 'revoked' `).bind(now, now, installationId).run(); revoked += Number(credentialResult?.meta?.changes || 0); // Незавершённое решение старой установки больше некому исполнять. // Отменяем его, снимаем аренду и сразу очищаем чувствительный текст. await env.DB.prepare(` UPDATE decision_requests SET status = 'cancelled', execution_status = 'installation_forgotten', execution_error = 'INSTALLATION_FORGOTTEN', lease_owner = '', lease_token_hash = '', lease_expires_at = 0, reason = '', manager_task = '', recommendation = '', context_json = '[]', resolution_text = '', sensitive_ciphertext = '', sensitive_iv = '', sensitive_key_version = 0, sensitive_purged_at = ?, updated_at = ? WHERE installation_id = ? AND status IN ('pending', 'resolved') `).bind(now, now, installationId).run(); await env.DB.prepare( 'DELETE FROM client_states WHERE installation_id = ?' ).bind(installationId).run(); await env.DB.prepare( 'DELETE FROM installation_stops WHERE installation_id = ?' ).bind(installationId).run().catch(() => {}); const result = await env.DB.prepare( 'DELETE FROM installations WHERE installation_id = ?' ).bind(installationId).run(); removed += Number(result?.meta?.changes || 0); } await writeAudit( env, sessionUser, 'installations_forgotten', ids.join(', ').slice(0, 200), request ); return jsonResponse({ ok: true, removed, revoked, processed: ids.length, skipped: Math.max(0, requestedIds.length - ids.length), }, 200, true); } // Индивидуальная остановка установки: только администратор if (path === '/admin/installation-stop' && request.method === 'POST') { if (!canManageUsers(sessionUser)) { return jsonResponse({ ok: false, error: 'FORBIDDEN' }, 403, true); } if (!isSameOriginRequest(request)) { return jsonResponse({ ok: false, error: 'BAD_ORIGIN' }, 403, true); } const body = await readJsonBody(request); const installationId = cleanText(body?.installation_id, 160); const stopped = body?.stopped === true; if (!installationId) { return jsonResponse({ ok: false, error: 'INSTALLATION_REQUIRED' }, 400, true); } await writeInstallationStop(env, installationId, stopped, sessionUser); await writeAudit( env, sessionUser, stopped ? 'installation_stop_on' : 'installation_stop_off', installationId, request ); return jsonResponse({ ok: true, installation_id: installationId, stopped }, 200, true); } // Адрес и ключ конфиг-воркера для сборки загрузчиков if (path === '/admin/loader-config' && request.method === 'POST') { if (!canManageUsers(sessionUser)) { return jsonResponse({ ok: false, error: 'FORBIDDEN' }, 403, true); } if (!isSameOriginRequest(request)) { return jsonResponse({ ok: false, error: 'BAD_ORIGIN' }, 403, true); } const body = await readJsonBody(request); for (const [settingKey, value] of [ ['config_base_url', cleanText(body?.base, 300)], ['config_key', cleanText(body?.key, 200)], ]) { await env.DB.prepare(` INSERT INTO app_settings (key, value, updated_at, updated_by) VALUES (?, ?, ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at, updated_by = excluded.updated_by `).bind( settingKey, value, Date.now(), cleanText(sessionUser?.login, 80) ).run(); } await writeAudit(env, sessionUser, 'loader_config_updated', '', request); return jsonResponse({ ok: true }, 200, true); } // Страница со всеми загрузчиками: удобно подключать менеджеров if (path === '/admin/loaders' && request.method === 'GET') { if (!canManageUsers(sessionUser)) { return htmlResponse( '

' + 'Раздел доступен только администратору. Назад

', 403 ); } return handleLoadersPage(request, env, sessionUser); } // Напоминание о перезапуске браузера: задаёт только администратор if (path === '/admin/restart-reminder' && request.method === 'POST') { if (!canManageUsers(sessionUser)) { return jsonResponse({ ok: false, error: 'FORBIDDEN' }, 403, true); } if (!isSameOriginRequest(request)) { return jsonResponse({ ok: false, error: 'BAD_ORIGIN' }, 403, true); } const body = await readJsonBody(request); await writeRestartReminder(env, { enabled: body?.enabled === true, time: cleanText(body?.time, 5), text: cleanText(body?.text, 400), }, sessionUser); await writeAudit( env, sessionUser, 'restart_reminder_updated', (body?.enabled ? 'вкл ' : 'выкл ') + cleanText(body?.time, 5), request ); return jsonResponse( { ok: true, restart_reminder: await readRestartReminder(env) }, 200, true ); } // Актуальные версии скриптов: задаёт только администратор if (path === '/admin/versions' && request.method === 'POST') { if (!canManageUsers(sessionUser)) { return jsonResponse({ ok: false, error: 'FORBIDDEN' }, 403, true); } if (!isSameOriginRequest(request)) { return jsonResponse({ ok: false, error: 'BAD_ORIGIN' }, 403, true); } const body = await readJsonBody(request); const applied = {}; for (const platform of VERSION_PLATFORMS) { if (typeof body?.[platform] === 'string') { await writeLatestVersion(env, platform, body[platform], sessionUser); applied[platform] = cleanText(body[platform], 20); } const loaderKey = 'loader_' + platform; if (typeof body?.[loaderKey] === 'string') { await env.DB.prepare(` INSERT INTO app_settings (key, value, updated_at, updated_by) VALUES (?, ?, ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at, updated_by = excluded.updated_by `).bind( 'loader_url_' + platform, cleanText(body[loaderKey], 500), Date.now(), cleanText(sessionUser?.login, 80) ).run(); } } await writeAudit( env, sessionUser, 'versions_updated', Object.entries(applied).map(([k, v]) => k + '=' + v).join(', '), request ); return jsonResponse( { ok: true, versions: await readLatestVersions(env) }, 200, true ); } if (path === '/admin') { await ensureDecisionSchema(env); return htmlResponse(dashboardHtml(sessionUser), 200); } // ---------- Данные ---------- if (path.startsWith('/api/admin/')) { // Решения принимаются через POST. Проверяем их до общего // ограничения административных API только методом GET. if ( request.method === 'POST' && path === '/api/admin/decisions/resolve' ) { await ensureDecisionSchema(env); return await handleAdminDecisionResolve( request, env, sessionUser ); } if ( request.method === 'POST' && path === '/api/admin/decisions/test' ) { if (!canManageUsers(sessionUser)) { return jsonResponse({ ok: false, error: 'FORBIDDEN' }, 403); } await ensureDecisionSchema(env); return await handleAdminDecisionTest( request, env, sessionUser ); } if ( request.method === 'POST' && path === '/api/admin/installations/invite' ) { if (!canManageUsers(sessionUser)) { return jsonResponse({ ok: false, error: 'FORBIDDEN' }, 403); } return await handleAdminInstallationInvite( request, env, sessionUser ); } if ( request.method === 'POST' && path === '/api/admin/stages/merge' ) { if (!canManageUsers(sessionUser)) { return jsonResponse({ ok: false, error: 'FORBIDDEN' }, 403); } return await handleAdminStageMerge( request, env, sessionUser ); } if ( request.method === 'POST' && path === '/api/admin/installations/rotate' ) { if (!canManageUsers(sessionUser)) { return jsonResponse({ ok: false, error: 'FORBIDDEN' }, 403); } return await handleAdminInstallationRotate( request, env, sessionUser ); } if ( request.method === 'POST' && path === '/api/admin/installations/revoke' ) { if (!canManageUsers(sessionUser)) { return jsonResponse({ ok: false, error: 'FORBIDDEN' }, 403); } return await handleAdminInstallationRevoke( request, env, sessionUser ); } if (request.method !== 'GET') { return jsonResponse( { ok: false, error: 'METHOD_NOT_ALLOWED' }, 405 ); } if (path === '/api/admin/summary') { return await handleAdminSummary(env, sessionUser); } if (path === '/api/admin/installations') { return await handleAdminInstallations(env, sessionUser); } if (path === '/api/admin/installations/security') { if (!canManageUsers(sessionUser)) { return jsonResponse({ ok: false, error: 'FORBIDDEN' }, 403); } return await handleAdminInstallationSecurity(env); } if (path === '/api/admin/clients') { return await handleAdminClients(env, url, sessionUser); } if (path === '/api/admin/decisions') { await ensureDecisionSchema(env); return await handleAdminDecisions(env, url, sessionUser); } if (path === '/api/admin/events') { // Подробные логи ошибок доступны только администратору if (!canSeeErrors(sessionUser)) { return jsonResponse({ ok: false, error: 'FORBIDDEN' }, 403); } return await handleAdminEvents(env, url, sessionUser); } if (path === '/api/admin/export') { if (!canSeeErrors(sessionUser)) { return jsonResponse({ ok: false, error: 'FORBIDDEN' }, 403); } await ensureDecisionSchema(env); await writeAudit(env, sessionUser, 'export', url.search, request); return await handleAdminExport(env, url, sessionUser); } return jsonResponse({ ok: false, error: 'NOT_FOUND' }, 404); } return jsonResponse({ ok: false, error: 'NOT_FOUND' }, 404); } // ---------- Упрощённый OKZ API без ключей в браузере ---------- // Anthropic API key остаётся только в Secret Worker. Маршрут имеет // отдельный небольшой размер запроса, rate limit и дневной бюджет. if (path === '/api/v1/ai/okz/messages') { if (request.method !== 'POST') { return jsonResponse( { ok: false, error: 'METHOD_NOT_ALLOWED' }, 405, true ); } const clientMarker = cleanText( request.headers.get('X-OKZ-Client'), 80 ); if (!/^OKZ-AI-Assistant\/\d+\.\d+\.\d+$/.test(clientMarker)) { return jsonResponse( { ok: false, error: 'OKZ_CLIENT_REQUIRED' }, 403, true ); } const okzIdentity = await createOkzPublicIdentity(request); await enforceRateLimit( env.AI_RATE_LIMITER || env.API_RATE_LIMITER, `okz:${okzIdentity.credential_id}`, 'AI_RATE_LIMITED' ); await enforceOkzDailyBudget(env, okzIdentity); const payload = await readJsonBodyWithLimit( request, MAX_OKZ_AI_BODY_BYTES ); return await handleAiGatewayMessage( env, { ...payload, task: 'dossier', installation_id: okzIdentity.installation_id, max_tokens: 900, }, okzIdentity, requestId ); } // ---------- Централизованный Anthropic API Gateway ---------- // Ключ Anthropic хранится только в Secret Worker. Клиентский скрипт // допускается сюда исключительно по персональному iat-токену. if (path === '/api/v1/ai/messages') { if (request.method !== 'POST') { return jsonResponse( { ok: false, error: 'METHOD_NOT_ALLOWED' }, 405, true ); } const installationAuth = await authenticateInstallation(request, env); if (!installationAuth) { return jsonResponse( { ok: false, error: 'UNAUTHORIZED' }, 401, true ); } await enforceRateLimit( env.AI_RATE_LIMITER || env.API_RATE_LIMITER, `ai:${installationAuth.credential_id}`, 'AI_RATE_LIMITED' ); const payload = await readJsonBodyWithLimit( request, MAX_AI_BODY_BYTES ); assertPayloadInstallation( payload, installationAuth.installation_id ); return await handleAiGatewayMessage( env, payload, installationAuth, requestId ); } // ---------- Серверная расшифровка голосовых ---------- // В браузере менеджера нет Groq-ключа. Аудио проксируется напрямую // в Groq по персональному iat-токену и нигде не сохраняется. if (path === '/api/v1/audio/transcriptions') { if (request.method !== 'POST') { return jsonResponse( { ok: false, error: 'METHOD_NOT_ALLOWED' }, 405, true ); } const installationAuth = await authenticateInstallation(request, env); if (!installationAuth) { return jsonResponse( { ok: false, error: 'UNAUTHORIZED' }, 401, true ); } await enforceRateLimit( env.AI_RATE_LIMITER || env.API_RATE_LIMITER, `audio:${installationAuth.credential_id}`, 'AUDIO_RATE_LIMITED' ); return await handleAudioTranscription( request, env, installationAuth, requestId ); } const decisionRoutes = new Set([ '/api/v1/decisions/request', '/api/v1/decisions/poll', '/api/v1/decisions/lease/renew', '/api/v1/decisions/prepared', '/api/v1/decisions/ack', ]); if (decisionRoutes.has(path)) { if (request.method !== 'POST') { return jsonResponse( { ok: false, error: 'METHOD_NOT_ALLOWED' }, 405, true ); } const installationAuth = await authenticateInstallation(request, env); if (!installationAuth) { return jsonResponse( { ok: false, error: 'UNAUTHORIZED' }, 401, true ); } await enforceRateLimit( env.API_RATE_LIMITER, `decision:${installationAuth.credential_id}`, 'API_RATE_LIMITED' ); const payload = await readJsonBody(request); assertPayloadInstallation(payload, installationAuth.installation_id); if (path.endsWith('/request')) { return await handleDecisionRequest(env, payload, installationAuth); } if (path.endsWith('/poll')) { return await handleDecisionPoll(env, payload, installationAuth); } if (path.endsWith('/lease/renew')) { return await handleDecisionLeaseRenew(env, payload, installationAuth); } if (path.endsWith('/prepared')) { return await handleDecisionPrepared(env, payload, installationAuth); } return await handleDecisionAck(env, payload, installationAuth); } const memoryRoute = path.match(/^\/api\/v1\/memory\/(preview|create-or-attach|get-context|append-confirmed|unlink|delete-memory)$/); if (memoryRoute) { if (request.method !== 'POST') return jsonResponse({ ok: false, error: 'METHOD_NOT_ALLOWED' }, 405, true); const identity = await authenticateInstallation(request, env); if (!identity) return jsonResponse({ ok: false, error: 'UNAUTHORIZED' }, 401, true); assertMemoryIdentity(identity); await enforceRateLimit(env.API_RATE_LIMITER, `memory:${identity.credential_id}`, 'API_RATE_LIMITED'); const payload = await readJsonBodyWithLimit(request, MAX_BODY_BYTES); assertPayloadInstallation(payload, identity.installation_id); const handlers = {preview:handleMemoryPreview,'create-or-attach':handleMemoryCreateOrAttach,'get-context':handleMemoryGetContext,'append-confirmed':handleMemoryAppendConfirmed,'unlink':handleMemoryUnlink,'delete-memory':handleMemoryDelete}; return await handlers[memoryRoute[1]](env, identity, payload); } const backupRoute = path.match(/^\/api\/v1\/(max|telegram|whatsapp|gemspace)\/backup\/(save|load)$/); if (backupRoute) { if (request.method !== 'POST') { return jsonResponse({ ok: false, error: 'METHOD_NOT_ALLOWED' }, 405, true); } const identity = await authenticateInstallation(request, env); if (!identity) { return jsonResponse({ ok: false, error: 'UNAUTHORIZED' }, 401, true); } if (String(identity.platform).toLowerCase() !== backupRoute[1] || !identity.manager_id) { throw new PublicError('BACKUP_STAGE_REQUIRED', 403); } await enforceRateLimit( env.API_RATE_LIMITER, `backup:${identity.credential_id}`, 'API_RATE_LIMITED' ); const payload = await readJsonBodyWithLimit(request, MAX_BODY_BYTES); assertPayloadInstallation(payload, identity.installation_id); return backupRoute[2] === 'save' ? handleMaxBackupSave(env, identity, payload) : handleMaxBackupLoad(env, identity, payload); } const ingestRoutes = new Set([ '/heartbeat', '/clients', '/events', '/snapshot', '/api/v1/heartbeat', '/api/v1/clients', '/api/v1/events', '/api/v1/snapshot', ]); if (ingestRoutes.has(path)) { if (request.method !== 'POST') { return jsonResponse( { ok: false, error: 'METHOD_NOT_ALLOWED' }, 405, true ); } const ingestAuth = await authenticateIngestRequest(request, env); if (!ingestAuth) { return jsonResponse( { ok: false, error: 'UNAUTHORIZED' }, 401, true ); } await enforceRateLimit( env.API_RATE_LIMITER, ingestAuth.auth_mode === 'installation' ? `ingest:${ingestAuth.credential_id}` : `legacy:${request.headers.get('CF-Connecting-IP') || 'unknown'}`, 'API_RATE_LIMITED' ); const payload = await readJsonBody(request); let trustedPayload = bindIngestIdentity(payload, ingestAuth); // Старый общий INGEST_TOKEN не содержит серверной личности. // Если стейдж ранее объединён с правильным, применяем псевдоним // ещё до записи heartbeat: ошибочное имя не сможет появиться снова. if (ingestAuth.auth_mode === 'legacy') { trustedPayload = await canonicalizeLegacyIngestPayload( env, trustedPayload ); } if (path.endsWith('/heartbeat') || path === '/heartbeat') { return await handleHeartbeat(env, trustedPayload); } if (path.endsWith('/clients') || path === '/clients') { return await handleClients(env, trustedPayload); } if (path.endsWith('/events') || path === '/events') { return await handleEvents(env, trustedPayload); } return await handleSnapshot(env, trustedPayload); } return jsonResponse( { ok: false, error: 'NOT_FOUND', request_id: requestId }, 404 ); } catch (error) { const publicError = error instanceof PublicError ? error : new PublicError('INTERNAL_ERROR', 500); // Ожидаемые 4xx (например, второй клик по уже решённой карточке) // возвращаются интерфейсу как JSON, но не засоряют журнал Worker ошибками. if (!(error instanceof PublicError) || publicError.status >= 500) { console.error('monitor_request_failed', { request_id: requestId, name: cleanText(error?.name || 'Error', 80), message: redactSensitive(error?.message || String(error), 500), }); } return jsonResponse( { ok: false, error: publicError.code, request_id: requestId, }, publicError.status, true ); } }, // Ретеншн. Требует cron-триггер в wrangler.toml, например: // [triggers] crons = ["0 3 * * *"] async scheduled(event, env, ctx) { ctx.waitUntil(runRetention(env)); }, }; // События старше 14 дней, установки без сигнала 30 дней, // протухшие сессии и старый аудит удаляются автоматически. async function runRetention(env) { const now = Date.now(); const eventsBefore = now - 14 * 24 * 60 * 60 * 1000; const decisionsBefore = now - DECISION_RETENTION_MS; const sensitiveBefore = now - DECISION_SENSITIVE_RETENTION_MS; const installationsBefore = now - INSTALLATION_RETENTION_MS; const auditBefore = now - 90 * 24 * 60 * 60 * 1000; const aiUsageBeforeDay = moscowDayKey( now - AI_USAGE_RETENTION_DAYS * 24 * 60 * 60 * 1000 ); try { await ensureServerSchema(env); await expireDecisionRequests(env, now); await env.DB.prepare( 'DELETE FROM event_logs WHERE created_at < ?' ).bind(eventsBefore).run(); await env.DB.prepare( 'DELETE FROM ai_usage WHERE usage_day < ?' ).bind(aiUsageBeforeDay).run(); const stale = await env.DB.prepare( 'SELECT installation_id FROM installations WHERE last_seen_at < ?' ).bind(installationsBefore).all(); for (const row of stale.results || []) { await env.DB.prepare( 'DELETE FROM client_states WHERE installation_id = ?' ).bind(row.installation_id).run(); await env.DB.prepare( 'DELETE FROM installations WHERE installation_id = ?' ).bind(row.installation_id).run(); } await env.DB.prepare( 'DELETE FROM user_sessions WHERE expires_at < ?' ).bind(now).run(); await env.DB.prepare( 'DELETE FROM audit_log WHERE at < ?' ).bind(auditBefore).run(); await env.DB.prepare(` UPDATE decision_requests SET reason = '', manager_task = '', recommendation = '', context_json = '[]', resolution_text = '', sensitive_ciphertext = '', sensitive_iv = '', sensitive_key_version = 0, sensitive_purged_at = ? WHERE updated_at < ? AND sensitive_purged_at = 0 AND status IN ('executed', 'failed', 'expired', 'cancelled') `).bind(now, sensitiveBefore).run(); await env.DB.prepare( 'DELETE FROM decision_events WHERE at < ?' ).bind(decisionsBefore).run(); await env.DB.prepare(` DELETE FROM decision_requests WHERE updated_at < ? AND status IN ('executed', 'failed', 'expired', 'cancelled') `).bind(decisionsBefore).run(); await env.DB.prepare(` UPDATE enrollment_codes SET status = 'expired' WHERE status = 'active' AND expires_at < ? `).bind(now).run(); await env.DB.prepare(` DELETE FROM enrollment_codes WHERE created_at < ? AND status IN ('used', 'expired') `).bind(now - 30 * 24 * 60 * 60 * 1000).run(); } catch (error) { // Ретеншн не должен ломать приём данных, но ошибка должна остаться в логах. console.error('monitor_retention_failed', { name: cleanText(error?.name || 'Error', 80), message: redactSensitive(error?.message || String(error), 500), }); } } class PublicError extends Error { constructor(code, status = 400) { super(code); this.code = code; this.status = status; } } // ================================================================ // ЦЕНТР РЕШЕНИЙ: КРАСНАЯ ЗОНА И ЗВОНКИ // Скрипты создают запросы и забирают решения через /api/v1/decisions/*. // Тимлидер, хед и администратор работают с ними на защищённом сайте. // ================================================================ let serverSchemaPromise = null; async function ensureServerSchema(env) { if (!serverSchemaPromise) { serverSchemaPromise = initializeServerSchema(env).catch((error) => { serverSchemaPromise = null; throw error; }); } return serverSchemaPromise; } async function initializeServerSchema(env) { const coreStatements = [ `CREATE TABLE IF NOT EXISTS schema_migrations ( version INTEGER PRIMARY KEY, applied_at INTEGER NOT NULL )`, `CREATE TABLE IF NOT EXISTS installations ( installation_id TEXT PRIMARY KEY, manager_id TEXT NOT NULL DEFAULT '', manager_name TEXT NOT NULL DEFAULT '', stage TEXT NOT NULL DEFAULT '', platform TEXT NOT NULL DEFAULT '', script_version TEXT NOT NULL DEFAULT '', session_id TEXT NOT NULL DEFAULT '', state TEXT NOT NULL DEFAULT 'unknown', paused INTEGER NOT NULL DEFAULT 0, busy INTEGER NOT NULL DEFAULT 0, online INTEGER NOT NULL DEFAULT 0, page_visible INTEGER NOT NULL DEFAULT 0, page_hidden_since INTEGER NOT NULL DEFAULT 0, clients_total INTEGER NOT NULL DEFAULT 0, clients_active INTEGER NOT NULL DEFAULT 0, started_at INTEGER NOT NULL DEFAULT 0, last_seen_at INTEGER NOT NULL DEFAULT 0, last_reason TEXT NOT NULL DEFAULT '', created_at INTEGER NOT NULL DEFAULT 0, updated_at INTEGER NOT NULL DEFAULT 0 )`, `CREATE TABLE IF NOT EXISTS client_states ( installation_id TEXT NOT NULL, client_key TEXT NOT NULL, client_label TEXT NOT NULL DEFAULT '', platform TEXT NOT NULL DEFAULT '', enabled INTEGER NOT NULL DEFAULT 1, status TEXT NOT NULL DEFAULT 'unknown', on_call INTEGER NOT NULL DEFAULT 0, awaiting_human INTEGER NOT NULL DEFAULT 0, last_in_at INTEGER NOT NULL DEFAULT 0, last_out_at INTEGER NOT NULL DEFAULT 0, last_sender TEXT NOT NULL DEFAULT '', unanswered_since INTEGER NOT NULL DEFAULT 0, next_action_at INTEGER NOT NULL DEFAULT 0, last_success_at INTEGER NOT NULL DEFAULT 0, last_error_code TEXT NOT NULL DEFAULT '', last_trace_id TEXT NOT NULL DEFAULT '', updated_at INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (installation_id, client_key) )`, `CREATE TABLE IF NOT EXISTS event_logs ( id INTEGER PRIMARY KEY AUTOINCREMENT, event_id TEXT NOT NULL UNIQUE, trace_id TEXT NOT NULL DEFAULT '', installation_id TEXT NOT NULL, client_key TEXT NOT NULL DEFAULT '', manager_id TEXT NOT NULL DEFAULT '', stage TEXT NOT NULL DEFAULT '', platform TEXT NOT NULL DEFAULT '', script_version TEXT NOT NULL DEFAULT '', event_type TEXT NOT NULL DEFAULT 'diagnostic', step TEXT NOT NULL DEFAULT '', level TEXT NOT NULL DEFAULT 'info', code TEXT NOT NULL DEFAULT '', message TEXT NOT NULL DEFAULT '', details_json TEXT NOT NULL DEFAULT '{}', created_at INTEGER NOT NULL, received_at INTEGER NOT NULL )`, `CREATE TABLE IF NOT EXISTS installation_credentials ( credential_id TEXT PRIMARY KEY, installation_id TEXT NOT NULL UNIQUE, token_hash TEXT NOT NULL, activation_code_id TEXT NOT NULL DEFAULT '', status TEXT NOT NULL DEFAULT 'active', manager_id TEXT NOT NULL DEFAULT '', manager_name TEXT NOT NULL DEFAULT '', stage TEXT NOT NULL DEFAULT '', platform TEXT NOT NULL DEFAULT '', created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, last_used_at INTEGER NOT NULL DEFAULT 0, rotated_at INTEGER NOT NULL DEFAULT 0, revoked_at INTEGER NOT NULL DEFAULT 0, last_ip TEXT NOT NULL DEFAULT '', last_user_agent TEXT NOT NULL DEFAULT '' )`, `CREATE TABLE IF NOT EXISTS enrollment_codes ( code_id TEXT PRIMARY KEY, code_hash TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'active', manager_id TEXT NOT NULL DEFAULT '', manager_name TEXT NOT NULL DEFAULT '', stage TEXT NOT NULL DEFAULT '', platform TEXT NOT NULL DEFAULT '', created_by INTEGER NOT NULL DEFAULT 0, created_login TEXT NOT NULL DEFAULT '', created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL, used_at INTEGER NOT NULL DEFAULT 0, installation_id TEXT NOT NULL DEFAULT '' )`, `CREATE TABLE IF NOT EXISTS manager_stages ( manager_id TEXT PRIMARY KEY, canonical_stage TEXT NOT NULL, stage_norm TEXT NOT NULL UNIQUE, active INTEGER NOT NULL DEFAULT 1, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL )`, `CREATE TABLE IF NOT EXISTS manager_stage_aliases ( alias_norm TEXT PRIMARY KEY, manager_id TEXT NOT NULL, canonical_stage TEXT NOT NULL, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL )`, `CREATE TABLE IF NOT EXISTS max_stage_backups ( manager_id TEXT NOT NULL, platform TEXT NOT NULL, record_key TEXT NOT NULL, ciphertext TEXT NOT NULL, iv TEXT NOT NULL, key_version INTEGER NOT NULL, updated_at INTEGER NOT NULL, PRIMARY KEY (manager_id, platform, record_key) )`, `CREATE INDEX IF NOT EXISTS idx_installations_last_seen ON installations(last_seen_at DESC)`, `CREATE INDEX IF NOT EXISTS idx_installations_stage ON installations(stage, last_seen_at DESC)`, `CREATE INDEX IF NOT EXISTS idx_clients_status ON client_states(status, updated_at DESC)`, `CREATE INDEX IF NOT EXISTS idx_events_created ON event_logs(created_at DESC)`, `CREATE INDEX IF NOT EXISTS idx_events_installation ON event_logs(installation_id, created_at DESC)`, `CREATE INDEX IF NOT EXISTS idx_manager_stages_active ON manager_stages(active, canonical_stage)`, ]; // D1 Free допускает ограниченное число запросов за один вызов Worker. // Сначала получаем каталог SQLite и создаём только реально отсутствующие // объекты, а не повторяем все CREATE IF NOT EXISTS на каждом cold start. await env.DB.prepare(coreStatements[0]).run(); const objectRows = await env.DB.prepare(` SELECT name FROM sqlite_master WHERE type IN ('table', 'index') `).all(); const existingObjects = new Set( (objectRows.results || []).map((row) => String(row.name).toLowerCase()) ); for (const sql of coreStatements.slice(1)) { const match = sql.match( /CREATE\s+(?:UNIQUE\s+)?(?:TABLE|INDEX)\s+IF\s+NOT\s+EXISTS\s+([a-z0-9_]+)/i ); const objectName = match?.[1]?.toLowerCase() || ''; if (objectName && existingObjects.has(objectName)) continue; await env.DB.prepare(sql).run(); if (objectName) existingObjects.add(objectName); } await ensureAuthSchema(env); await ensureDecisionSchema(env); await applySchemaMigrations(env); } async function tableColumns(env, tableName) { const safeName = String(tableName).replace(/[^a-z0-9_]/gi, ''); const result = await env.DB.prepare(`PRAGMA table_info(${safeName})`).all(); return new Set((result.results || []).map((row) => String(row.name))); } async function addColumnsIfMissing(env, tableName, definitions) { let columns = await tableColumns(env, tableName); const safeTable = String(tableName).replace(/[^a-z0-9_]/gi, ''); for (const [columnName, definition] of definitions) { if (columns.has(columnName)) continue; const safeColumn = String(columnName).replace(/[^a-z0-9_]/gi, ''); try { await env.DB.prepare( `ALTER TABLE ${safeTable} ADD COLUMN ${safeColumn} ${definition}` ).run(); } catch (error) { const refreshed = await tableColumns(env, tableName); if (!refreshed.has(columnName)) throw error; columns = refreshed; } columns = new Set([...columns, columnName]); } } async function applySchemaMigrations(env) { const now = Date.now(); const migrations = [ // Одна сводная миграция вместо нескольких мелких: так обновление старой // базы укладывается в лимит запросов D1 Free даже при первом POST-запросе. [5, async () => { await addColumnsIfMissing(env, 'decision_requests', [ ['lease_owner', "TEXT NOT NULL DEFAULT ''"], ['lease_token_hash', "TEXT NOT NULL DEFAULT ''"], ['lease_expires_at', 'INTEGER NOT NULL DEFAULT 0'], ['execution_attempts', 'INTEGER NOT NULL DEFAULT 0'], ['next_attempt_at', 'INTEGER NOT NULL DEFAULT 0'], ['prepared_message_hash', "TEXT NOT NULL DEFAULT ''"], ['prepared_at', 'INTEGER NOT NULL DEFAULT 0'], ['sensitive_ciphertext', "TEXT NOT NULL DEFAULT ''"], ['sensitive_iv', "TEXT NOT NULL DEFAULT ''"], ['sensitive_key_version', 'INTEGER NOT NULL DEFAULT 0'], ['sensitive_purged_at', 'INTEGER NOT NULL DEFAULT 0'], ]); await env.DB.prepare(` CREATE INDEX IF NOT EXISTS idx_decisions_delivery_queue ON decision_requests(installation_id, status, next_attempt_at, lease_expires_at) `).run(); await addColumnsIfMissing(env, 'installation_credentials', [ ['activation_code_id', "TEXT NOT NULL DEFAULT ''"], ]); await env.DB.prepare(` CREATE UNIQUE INDEX IF NOT EXISTS idx_credentials_activation_code ON installation_credentials(activation_code_id) WHERE activation_code_id <> '' `).run(); }], [6, async () => { // Один составной PRIMARY KEY одновременно обеспечивает уникальность // request_id внутри установки и быстрые суточные выборки по МСК. // Отдельные индексы не нужны, поэтому миграция укладывается в лимит D1. await env.DB.prepare(` CREATE TABLE IF NOT EXISTS ai_usage ( usage_day TEXT NOT NULL, installation_id TEXT NOT NULL, request_id TEXT NOT NULL, credential_id TEXT NOT NULL DEFAULT '', manager_id TEXT NOT NULL DEFAULT '', stage TEXT NOT NULL DEFAULT '', platform TEXT NOT NULL DEFAULT '', task TEXT NOT NULL, model TEXT NOT NULL, status TEXT NOT NULL, input_tokens INTEGER NOT NULL DEFAULT 0, cache_creation_input_tokens INTEGER NOT NULL DEFAULT 0, cache_read_input_tokens INTEGER NOT NULL DEFAULT 0, output_tokens INTEGER NOT NULL DEFAULT 0, estimated_cost_microusd INTEGER NOT NULL DEFAULT 0, upstream_status INTEGER NOT NULL DEFAULT 0, error_code TEXT NOT NULL DEFAULT '', latency_ms INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL, PRIMARY KEY (usage_day, installation_id, request_id) ) `).run(); }], [7, async () => { // Реестр превращает введённое человеком название стейджа в // постоянный manager_id. Повторная активация и другой мессенджер // больше не создают нового «менеджера» в CRM. await env.DB.prepare(` INSERT OR IGNORE INTO manager_stages ( manager_id, canonical_stage, stage_norm, active, created_at, updated_at ) SELECT manager_id, stage, stage_norm, 1, ?, ? FROM ( SELECT CASE WHEN TRIM(COALESCE(manager_id, '')) <> '' THEN manager_id ELSE 'mgr_' || LOWER(HEX(RANDOMBLOB(12))) END AS manager_id, TRIM(stage) AS stage, LOWER(TRIM(stage)) AS stage_norm, ROW_NUMBER() OVER ( PARTITION BY LOWER(TRIM(stage)) ORDER BY last_seen_at DESC, updated_at DESC ) AS stage_rank FROM installations WHERE TRIM(COALESCE(stage, '')) <> '' ) WHERE stage_rank = 1 `).bind(now, now).run(); await env.DB.prepare(` INSERT OR IGNORE INTO manager_stage_aliases ( alias_norm, manager_id, canonical_stage, created_at, updated_at ) SELECT stage_norm, manager_id, canonical_stage, ?, ? FROM manager_stages WHERE active = 1 `).bind(now, now).run(); }], [8, async () => { // CRM v4 хранит момент ухода вкладки в фон и восстанавливает // постоянный реестр из активных персональных установок. Благодаря // этому менеджер не пропадает даже после очистки старой телеметрии. await addColumnsIfMissing(env, 'installations', [ ['page_hidden_since', 'INTEGER NOT NULL DEFAULT 0'], ]); await env.DB.prepare(` INSERT OR IGNORE INTO manager_stages ( manager_id, canonical_stage, stage_norm, active, created_at, updated_at ) SELECT manager_id, stage, stage_norm, 1, ?, ? FROM ( SELECT CASE WHEN TRIM(COALESCE(manager_id, '')) <> '' THEN manager_id ELSE 'mgr_' || LOWER(HEX(RANDOMBLOB(12))) END AS manager_id, TRIM(stage) AS stage, LOWER(TRIM(stage)) AS stage_norm, ROW_NUMBER() OVER ( PARTITION BY LOWER(TRIM(stage)) ORDER BY last_used_at DESC, updated_at DESC ) AS stage_rank FROM installation_credentials WHERE status = 'active' AND TRIM(COALESCE(stage, '')) <> '' ) WHERE stage_rank = 1 `).bind(now, now).run(); await env.DB.prepare(` INSERT OR IGNORE INTO manager_stage_aliases ( alias_norm, manager_id, canonical_stage, created_at, updated_at ) SELECT stage_norm, manager_id, canonical_stage, ?, ? FROM manager_stages WHERE active = 1 `).bind(now, now).run(); await env.DB.prepare(` UPDATE installations SET manager_id = COALESCE(( SELECT s.manager_id FROM manager_stages s WHERE s.active = 1 AND s.stage_norm = LOWER(TRIM(installations.stage)) LIMIT 1 ), manager_id), manager_name = COALESCE(( SELECT s.canonical_stage FROM manager_stages s WHERE s.active = 1 AND s.stage_norm = LOWER(TRIM(installations.stage)) LIMIT 1 ), manager_name), stage = COALESCE(( SELECT s.canonical_stage FROM manager_stages s WHERE s.active = 1 AND s.stage_norm = LOWER(TRIM(installations.stage)) LIMIT 1 ), stage), updated_at = ? WHERE TRIM(COALESCE(stage, '')) <> '' `).bind(now).run(); await env.DB.prepare(` UPDATE installation_credentials SET manager_id = COALESCE(( SELECT s.manager_id FROM manager_stages s WHERE s.active = 1 AND s.stage_norm = LOWER(TRIM(installation_credentials.stage)) LIMIT 1 ), manager_id), manager_name = COALESCE(( SELECT s.canonical_stage FROM manager_stages s WHERE s.active = 1 AND s.stage_norm = LOWER(TRIM(installation_credentials.stage)) LIMIT 1 ), manager_name), stage = COALESCE(( SELECT s.canonical_stage FROM manager_stages s WHERE s.active = 1 AND s.stage_norm = LOWER(TRIM(installation_credentials.stage)) LIMIT 1 ), stage), updated_at = ? WHERE status = 'active' AND TRIM(COALESCE(stage, '')) <> '' `).bind(now).run(); await env.DB.prepare(` UPDATE enrollment_codes SET manager_id = COALESCE(( SELECT s.manager_id FROM manager_stages s WHERE s.active = 1 AND s.stage_norm = LOWER(TRIM(enrollment_codes.stage)) LIMIT 1 ), manager_id), manager_name = COALESCE(( SELECT s.canonical_stage FROM manager_stages s WHERE s.active = 1 AND s.stage_norm = LOWER(TRIM(enrollment_codes.stage)) LIMIT 1 ), manager_name), stage = COALESCE(( SELECT s.canonical_stage FROM manager_stages s WHERE s.active = 1 AND s.stage_norm = LOWER(TRIM(enrollment_codes.stage)) LIMIT 1 ), stage) WHERE status = 'active' AND TRIM(COALESCE(stage, '')) <> '' `).run(); }], // Таблица резервных копий создаётся в coreStatements до миграций. [9, async () => {}], [10, async () => { const statements = [ `CREATE TABLE IF NOT EXISTS memory_people ( person_id TEXT PRIMARY KEY, manager_id TEXT NOT NULL, memory_key TEXT NOT NULL, dossier_ciphertext TEXT NOT NULL DEFAULT '', dossier_iv TEXT NOT NULL DEFAULT '', dossier_key_version INTEGER NOT NULL DEFAULT 0, revision INTEGER NOT NULL DEFAULT 0, incomplete INTEGER NOT NULL DEFAULT 1, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, UNIQUE(manager_id, memory_key), UNIQUE(manager_id, person_id) )`, `CREATE TABLE IF NOT EXISTS memory_chat_links ( manager_id TEXT NOT NULL, platform TEXT NOT NULL, client_key TEXT NOT NULL, person_id TEXT NOT NULL, linked_at INTEGER NOT NULL, source TEXT NOT NULL, import_complete INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(manager_id, platform, client_key), FOREIGN KEY(manager_id, person_id) REFERENCES memory_people(manager_id, person_id) )`, `CREATE INDEX IF NOT EXISTS memory_links_person ON memory_chat_links(manager_id, person_id)`, `CREATE TABLE IF NOT EXISTS memory_messages ( manager_id TEXT NOT NULL, person_id TEXT NOT NULL, platform TEXT NOT NULL, client_key TEXT NOT NULL, message_id TEXT NOT NULL, direction TEXT NOT NULL CHECK(direction IN ('in','out')), time INTEGER NOT NULL, type TEXT NOT NULL CHECK(type IN ('text','voice_untranscribed')), ciphertext TEXT NOT NULL, iv TEXT NOT NULL, key_version INTEGER NOT NULL, created_at INTEGER NOT NULL, PRIMARY KEY(manager_id, person_id, platform, client_key, message_id), FOREIGN KEY(manager_id, person_id) REFERENCES memory_people(manager_id, person_id) )`, `CREATE INDEX IF NOT EXISTS memory_messages_person ON memory_messages(manager_id, person_id, time)`, `CREATE TABLE IF NOT EXISTS memory_link_events ( event_id TEXT PRIMARY KEY, manager_id TEXT NOT NULL, person_id TEXT NOT NULL, platform TEXT NOT NULL, client_key TEXT NOT NULL, action TEXT NOT NULL, installation_id TEXT NOT NULL, created_at INTEGER NOT NULL )` ]; for (const sql of statements) await env.DB.prepare(sql).run(); }], ]; const appliedRows = await env.DB.prepare( 'SELECT version FROM schema_migrations' ).all(); const applied = new Set( (appliedRows.results || []).map((row) => Number(row.version)) ); for (const [version, migrate] of migrations) { if (applied.has(version)) continue; await migrate(); await env.DB.prepare(` INSERT OR IGNORE INTO schema_migrations (version, applied_at) VALUES (?, ?) `).bind(version, now).run(); } if (CURRENT_SCHEMA_VERSION !== migrations.at(-1)[0]) { throw new Error('SCHEMA_VERSION_MISMATCH'); } } let decisionSchemaPromise = null; async function ensureDecisionSchema(env) { if (!decisionSchemaPromise) { decisionSchemaPromise = initializeDecisionSchema(env).catch((error) => { decisionSchemaPromise = null; throw error; }); } return decisionSchemaPromise; } async function initializeDecisionSchema(env) { const statements = [ `CREATE TABLE IF NOT EXISTS decision_requests ( id INTEGER PRIMARY KEY AUTOINCREMENT, request_id TEXT NOT NULL UNIQUE, installation_id TEXT NOT NULL, client_key TEXT NOT NULL, client_label TEXT NOT NULL DEFAULT '', manager_id TEXT NOT NULL DEFAULT '', manager_name TEXT NOT NULL DEFAULT '', stage TEXT NOT NULL DEFAULT '', platform TEXT NOT NULL DEFAULT '', decision_type TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'pending', reason TEXT NOT NULL DEFAULT '', manager_task TEXT NOT NULL DEFAULT '', recommendation TEXT NOT NULL DEFAULT '', context_json TEXT NOT NULL DEFAULT '[]', proposed_time_at INTEGER NOT NULL DEFAULT 0, proposed_time_text TEXT NOT NULL DEFAULT '', trace_id TEXT NOT NULL DEFAULT '', source TEXT NOT NULL DEFAULT 'script', created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, expires_at INTEGER NOT NULL, resolved_at INTEGER NOT NULL DEFAULT 0, resolved_by INTEGER NOT NULL DEFAULT 0, resolved_login TEXT NOT NULL DEFAULT '', resolution_action TEXT NOT NULL DEFAULT '', resolution_text TEXT NOT NULL DEFAULT '', resolution_time_at INTEGER NOT NULL DEFAULT 0, delivered_at INTEGER NOT NULL DEFAULT 0, delivery_attempts INTEGER NOT NULL DEFAULT 0, executed_at INTEGER NOT NULL DEFAULT 0, execution_status TEXT NOT NULL DEFAULT '', execution_error TEXT NOT NULL DEFAULT '', lease_owner TEXT NOT NULL DEFAULT '', lease_token_hash TEXT NOT NULL DEFAULT '', lease_expires_at INTEGER NOT NULL DEFAULT 0, execution_attempts INTEGER NOT NULL DEFAULT 0, next_attempt_at INTEGER NOT NULL DEFAULT 0, prepared_message_hash TEXT NOT NULL DEFAULT '', prepared_at INTEGER NOT NULL DEFAULT 0, sensitive_ciphertext TEXT NOT NULL DEFAULT '', sensitive_iv TEXT NOT NULL DEFAULT '', sensitive_key_version INTEGER NOT NULL DEFAULT 0, sensitive_purged_at INTEGER NOT NULL DEFAULT 0 )`, `CREATE TABLE IF NOT EXISTS decision_events ( id INTEGER PRIMARY KEY AUTOINCREMENT, request_id TEXT NOT NULL, at INTEGER NOT NULL, event_type TEXT NOT NULL, actor_user_id INTEGER NOT NULL DEFAULT 0, actor_login TEXT NOT NULL DEFAULT '', details_json TEXT NOT NULL DEFAULT '{}' )`, `CREATE INDEX IF NOT EXISTS idx_decisions_status_created ON decision_requests(status, created_at DESC)`, `CREATE INDEX IF NOT EXISTS idx_decisions_stage_status ON decision_requests(stage, status, created_at DESC)`, `CREATE INDEX IF NOT EXISTS idx_decisions_installation_status ON decision_requests(installation_id, status, updated_at DESC)`, `CREATE UNIQUE INDEX IF NOT EXISTS idx_decisions_open_client ON decision_requests(installation_id, client_key, decision_type) WHERE status IN ('pending', 'resolved')`, `CREATE INDEX IF NOT EXISTS idx_decision_events_request ON decision_events(request_id, at DESC)`, ]; for (const sql of statements) { await env.DB.prepare(sql).run(); } } function decisionChanges(result) { return Number(result?.meta?.changes || result?.changes || 0); } function normalizeDecisionContext(rawContext) { if (!Array.isArray(rawContext)) return []; return rawContext .slice(-DECISION_CONTEXT_LIMIT) .filter((item) => item && typeof item === 'object') .map((item) => ({ who: cleanText( item.who || item.sender || item.role || 'unknown', 40 ), text: redactSensitive(item.text || item.message, 900), time: cleanText(item.time, 80), at: safeTimestamp(item.at || item.timestamp), })) .filter((item) => item.text); } function parseDecisionContext(value) { try { const parsed = JSON.parse(String(value || '[]')); return Array.isArray(parsed) ? parsed : []; } catch (_) { return []; } } function bytesToBase64Url(bytes) { let binary = ''; for (const byte of bytes) binary += String.fromCharCode(byte); return btoa(binary) .replace(/\+/g, '-') .replace(/\//g, '_') .replace(/=+$/g, ''); } function base64UrlToBytes(value) { const normalized = String(value || '') .replace(/-/g, '+') .replace(/_/g, '/'); const padded = normalized + '='.repeat((4 - normalized.length % 4) % 4); const binary = atob(padded); return Uint8Array.from(binary, (char) => char.charCodeAt(0)); } function parseEncryptionKey(value) { const raw = String(value || '').trim(); let bytes; if (/^[a-f0-9]{64}$/i.test(raw)) { bytes = Uint8Array.from( raw.match(/.{2}/g).map((pair) => parseInt(pair, 16)) ); } else { try { bytes = base64UrlToBytes(raw); } catch (_) { bytes = new Uint8Array(); } } if (bytes.length !== 32) { throw new PublicError('DATA_ENCRYPTION_KEY_INVALID', 503); } return bytes; } function isDataEncryptionKeyValid(env) { try { parseEncryptionKey(env.DATA_ENCRYPTION_KEY); return true; } catch (_) { return false; } } let dataEncryptionKeyPromise = null; let dataEncryptionKeySource = ''; async function importDataEncryptionKey(env) { if (!env.DATA_ENCRYPTION_KEY) { throw new PublicError('DATA_ENCRYPTION_KEY_REQUIRED', 503); } const source = String(env.DATA_ENCRYPTION_KEY); if (!dataEncryptionKeyPromise || dataEncryptionKeySource !== source) { dataEncryptionKeySource = source; dataEncryptionKeyPromise = crypto.subtle.importKey( 'raw', parseEncryptionKey(source), { name: 'AES-GCM' }, false, ['encrypt', 'decrypt'] ).catch((error) => { dataEncryptionKeyPromise = null; dataEncryptionKeySource = ''; throw error; }); } return dataEncryptionKeyPromise; } function decisionAad(requestId, keyVersion = DATA_ENCRYPTION_KEY_VERSION) { return new TextEncoder().encode( `ai-monitor:decision:${requestId}:v${keyVersion}` ); } function maxBackupAad(identity, recordKey) { const platform = String(identity.platform || 'max').toLowerCase(); return new TextEncoder().encode( `ai-monitor:max-backup:${identity.manager_id}:${platform}:${recordKey}:v${DATA_ENCRYPTION_KEY_VERSION}` ); } // Shared memory identifiers never include names or profile text. function assertMemoryIdentity(identity) { if (!identity || !identity.manager_id || !['max', 'telegram', 'whatsapp', 'gemspace'].includes(identity.platform)) { throw new PublicError('MEMORY_IDENTITY_REQUIRED', 403); } } function memoryClientKey(payload) { const value = payload?.client_key; if (typeof value !== 'string' || !value.trim() || value.length > 500) throw new PublicError('MEMORY_CLIENT_KEY_INVALID', 400); return value; } async function memoryPersonByKey(env, identity, memoryKey) { assertMemoryIdentity(identity); if (typeof memoryKey !== 'string' || !/^[A-Za-z0-9_-]{43}$/.test(memoryKey)) throw new PublicError('MEMORY_KEY_INVALID', 400); const person = await env.DB.prepare('SELECT * FROM memory_people WHERE manager_id = ? AND memory_key = ?') .bind(identity.manager_id, memoryKey).first(); if (person) return person; const foreign = await env.DB.prepare('SELECT person_id FROM memory_people WHERE memory_key = ? LIMIT 1').bind(memoryKey).first(); throw new PublicError(foreign ? 'MEMORY_FORBIDDEN' : 'MEMORY_NOT_FOUND', foreign ? 403 : 404); } async function memoryLinkForChat(env, identity, clientKey) { return env.DB.prepare(`SELECT p.* FROM memory_chat_links l JOIN memory_people p ON p.manager_id = l.manager_id AND p.person_id = l.person_id WHERE l.manager_id = ? AND l.platform = ? AND l.client_key = ?`) .bind(identity.manager_id, identity.platform, clientKey).first(); } function memoryAad(managerId, personId, kind, recordId, keyVersion = DATA_ENCRYPTION_KEY_VERSION) { return new TextEncoder().encode(JSON.stringify(['ai-monitor:memory', managerId, personId, kind, recordId, keyVersion])); } function memoryRecordId(platform, clientKey, messageId) { return JSON.stringify([platform, clientKey, messageId]); } async function memoryEncrypt(env, managerId, personId, kind, recordId, value) { const key = await importDataEncryptionKey(env), iv = crypto.getRandomValues(new Uint8Array(12)); const encrypted = await crypto.subtle.encrypt({name:'AES-GCM',iv,additionalData:memoryAad(managerId,personId,kind,recordId),tagLength:128},key,new TextEncoder().encode(JSON.stringify(value))); return [bytesToBase64Url(new Uint8Array(encrypted)),bytesToBase64Url(iv),DATA_ENCRYPTION_KEY_VERSION]; } async function memoryDecrypt(env, managerId, personId, kind, recordId, ciphertext, iv, version) { try { if (Number(version) !== DATA_ENCRYPTION_KEY_VERSION) throw new Error('version'); const decode = value => Uint8Array.from(atob(value.replace(/-/g,'+').replace(/_/g,'/')), c=>c.charCodeAt(0)); const key = await importDataEncryptionKey(env); const plain = await crypto.subtle.decrypt({name:'AES-GCM',iv:decode(iv),additionalData:memoryAad(managerId,personId,kind,recordId,Number(version)),tagLength:128},key,decode(ciphertext)); return JSON.parse(new TextDecoder().decode(plain)); } catch (_) { throw new PublicError('MEMORY_DECRYPTION_FAILED',503); } } async function memoryDossier(env, identity, person) { if (!person.dossier_ciphertext) return []; return memoryDecrypt(env,identity.manager_id,person.person_id,'dossier','profile',person.dossier_ciphertext,person.dossier_iv,person.dossier_key_version); } async function memoryLinkedPerson(env, identity, payload) { assertMemoryIdentity(identity); const person = await memoryLinkForChat(env,identity,memoryClientKey(payload)); if (!person) throw new PublicError('MEMORY_NOT_FOUND',404); return person; } // Conservative lexical nomination only: quotations are historical data, not verified facts. function memoryPersonalQuotation(text) { return /daughter|son\b|children|family|wife|husband|mother|father|live in|work|years old|hobb|enjoy|love|доч|сын|дет[ьи]|семь|жен[аы]|муж|мам|пап|жив[уёе]|работ|лет\b|возраст|хобби|люблю|увлека|город/i.test(text); } function memoryRetainQuotations(entries) { const unique=[]; for(const entry of entries.filter(e=>e.source==='confirmed_message').sort((a,b)=>(a.time||0)-(b.time||0) || JSON.stringify([a.source_platform,a.source_client_key,a.source_message_id]).localeCompare(JSON.stringify([b.source_platform,b.source_client_key,b.source_message_id])))) { if(!unique.some(e=>e.source_platform===entry.source_platform && e.source_client_key===entry.source_client_key && e.source_message_id===entry.source_message_id)) unique.push(entry); } return [...entries.filter(e=>e.source==='manager_profile'),...unique.slice(0,12),...unique.slice(12).slice(-4)]; } async function handleMemoryGetContext(env, identity, payload, attempt=0) { const person = await memoryLinkedPerson(env,identity,payload); const limit = payload?.limit === undefined ? 40 : payload.limit; if (!Number.isInteger(limit) || limit < 1 || limit > 100) throw new PublicError('MEMORY_LIMIT_INVALID',400); const paging=payload.archive_page!==undefined; const page=payload.archive_page; if(paging && (!page || !Number.isSafeInteger(page.offset) || page.offset<0 || page.offset>10000000 || page.person_id!==person.person_id || page.revision!==person.revision)) throw new PublicError('MEMORY_REVISION_CONFLICT',409); const offset=paging?page.offset:0; const rows = await env.DB.prepare(`SELECT * FROM memory_messages WHERE manager_id=? AND person_id=? ORDER BY time DESC,platform,client_key,message_id LIMIT ? OFFSET ?`).bind(identity.manager_id,person.person_id,limit,offset).all(); const recent=[]; let bytes=0; for (const row of rows.results || []) { const value = await memoryDecrypt(env,identity.manager_id,person.person_id,'message',memoryRecordId(row.platform,row.client_key,row.message_id),row.ciphertext,row.iv,row.key_version); // The authenticated plaintext includes metadata as well as text, preventing row metadata substitution. if (value.direction!==row.direction || value.time!==row.time || value.type!==row.type) throw new PublicError('MEMORY_DECRYPTION_FAILED',503); bytes += new TextEncoder().encode(value.text).length; if (bytes > 96*1024) break; recent.push({platform:row.platform,client_key:row.client_key,message_id:row.message_id,...value}); } const links = await env.DB.prepare('SELECT DISTINCT platform FROM memory_chat_links WHERE manager_id=? AND person_id=? ORDER BY platform').bind(identity.manager_id,person.person_id).all(); const dossier=await memoryDossier(env,identity,person); const latest=await memoryLinkedPerson(env,identity,payload); if (latest.person_id!==person.person_id || latest.revision!==person.revision) { if (!paging && attempt<3) return handleMemoryGetContext(env,identity,payload,attempt+1); throw new PublicError('MEMORY_CONTEXT_CHANGED',503); } return jsonResponse({ok:true,person_id:person.person_id,revision:person.revision,dossier,recent,archive_next:recent.length ? {person_id:person.person_id,revision:person.revision,offset:offset+recent.length}:null,incomplete:Boolean(person.incomplete),platforms:(links.results||[]).map(r=>r.platform)},200,true); } function memoryMediaEnrichment(previous, next) { if (previous.direction!==next.direction || previous.time!==next.time || previous.text===next.text) return false; if (previous.type==='voice_untranscribed' && next.type==='text' && /^\[голосовое/i.test(previous.text) && /^\[голосовое/i.test(next.text)) return true; return previous.type==='text' && next.type==='text' && /^\[ФОТО [^\]]+\]: \[фото — ещё не проанализировано\]$/i.test(previous.text) && /^\[ФОТО [^\]]+\]: (?!\[фото — ещё не проанализировано\])\S/i.test(next.text); } async function handleMemoryAppendConfirmed(env, identity, payload, attempt=0) { const person=await memoryLinkedPerson(env,identity,payload), clientKey=memoryClientKey(payload); if (!Array.isArray(payload.messages) || payload.messages.length>100 || typeof payload.import_complete!=='boolean') throw new PublicError('MEMORY_BATCH_INVALID',400); if (new TextEncoder().encode(JSON.stringify(payload)).length>256*1024) throw new PublicError('MEMORY_BATCH_TOO_LARGE',413); const messages=payload.messages; for (const m of messages) { if (!m || typeof m.message_id!=='string' || !m.message_id.trim() || m.message_id.length>500 || !['in','out'].includes(m.direction) || !Number.isSafeInteger(m.time) || m.time<0 || !['text','voice_untranscribed'].includes(m.type) || typeof m.text!=='string' || m.text.length>16000 || m.confirmed===false || m.draft || m.pending || (m.status && m.status!=='confirmed') || m.ai_proposal) throw new PublicError('MEMORY_MESSAGE_INVALID',400); } let dossier=await memoryDossier(env,identity,person); if (payload.dossier !== undefined) { if (!Array.isArray(payload.dossier) || payload.dossier.length>50 || payload.expected_revision!==person.revision) throw new PublicError(payload.expected_revision!==person.revision?'MEMORY_REVISION_CONFLICT':'MEMORY_DOSSIER_INVALID',payload.expected_revision!==person.revision?409:400); if(payload.dossier.length>4) throw new PublicError('MEMORY_DOSSIER_INVALID',400); dossier=dossier.filter(e=>e.source==='manager_profile' || e.source_client_key!==clientKey || e.source_platform!==identity.platform); for (const entry of payload.dossier) { if (!entry || entry.source!=='confirmed_message' || entry.source_platform!==identity.platform || typeof entry.text!=='string' || !entry.text.trim() || entry.text.length>4000 || typeof entry.source_message_id!=='string') throw new PublicError('MEMORY_DOSSIER_SOURCE_INVALID',400); const source=await env.DB.prepare('SELECT * FROM memory_messages WHERE manager_id=? AND person_id=? AND platform=? AND client_key=? AND message_id=?').bind(identity.manager_id,person.person_id,identity.platform,clientKey,entry.source_message_id).first(); if (!source) throw new PublicError('MEMORY_DOSSIER_SOURCE_MISSING',400); const authoritative=await memoryDecrypt(env,identity.manager_id,person.person_id,'message',memoryRecordId(identity.platform,clientKey,source.message_id),source.ciphertext,source.iv,source.key_version); if(authoritative.direction!=='in' || authoritative.type!=='text' || entry.text!==authoritative.text.slice(0,240) || entry.text.length>240) throw new PublicError('MEMORY_DOSSIER_SOURCE_INVALID',400); if (!dossier.some(e=>e.text===entry.text && e.source_platform===entry.source_platform && e.source_client_key===clientKey && e.source_message_id===entry.source_message_id)) dossier.push({text:entry.text,source_platform:identity.platform,source_client_key:clientKey,source_message_id:entry.source_message_id,source:'confirmed_message',time:authoritative.time,updated_at:authoritative.time}); } dossier=memoryRetainQuotations(dossier); } const inserts=[], updates=[], seen=new Set(); for (const m of messages) { if(seen.has(m.message_id)) continue; seen.add(m.message_id); const exists=await env.DB.prepare('SELECT * FROM memory_messages WHERE manager_id=? AND person_id=? AND platform=? AND client_key=? AND message_id=?').bind(identity.manager_id,person.person_id,identity.platform,clientKey,m.message_id).first(); if(exists) { if(identity.platform==='max' && exists.direction===m.direction && exists.time===m.time) { const previous=await memoryDecrypt(env,identity.manager_id,person.person_id,'message',memoryRecordId(identity.platform,clientKey,m.message_id),exists.ciphertext,exists.iv,exists.key_version); if(previous.direction!==exists.direction || previous.time!==exists.time || previous.type!==exists.type) throw new PublicError('MEMORY_DECRYPTION_FAILED',503); if(memoryMediaEnrichment(previous,m)) { const revised=await memoryEncrypt(env,identity.manager_id,person.person_id,'message',memoryRecordId(identity.platform,clientKey,m.message_id),{direction:m.direction,time:m.time,type:m.type,text:m.text}); updates.push(env.DB.prepare(`UPDATE memory_messages SET type=?,ciphertext=?,iv=?,key_version=? WHERE manager_id=? AND person_id=? AND platform=? AND client_key=? AND message_id=? AND ciphertext=? AND EXISTS(SELECT 1 FROM memory_people WHERE manager_id=? AND person_id=? AND revision=?)`).bind(m.type,...revised,identity.manager_id,person.person_id,identity.platform,clientKey,m.message_id,exists.ciphertext,identity.manager_id,person.person_id,person.revision)); } } continue; } if(m.direction==='in' && m.type==='text' && memoryPersonalQuotation(m.text)) dossier.push({text:m.text.slice(0,240),source:'confirmed_message',source_platform:identity.platform,source_client_key:clientKey,source_message_id:m.message_id,time:m.time,updated_at:m.time}); const encrypted=await memoryEncrypt(env,identity.manager_id,person.person_id,'message',memoryRecordId(identity.platform,clientKey,m.message_id),{direction:m.direction,time:m.time,type:m.type,text:m.text}); inserts.push(env.DB.prepare(`INSERT OR IGNORE INTO memory_messages(manager_id,person_id,platform,client_key,message_id,direction,time,type,ciphertext,iv,key_version,created_at) SELECT ?,?,?,?,?,?,?,?,?,?,?,? FROM memory_people WHERE manager_id=? AND person_id=? AND revision=?`).bind(identity.manager_id,person.person_id,identity.platform,clientKey,m.message_id,m.direction,m.time,m.type,...encrypted,Date.now(),identity.manager_id,person.person_id,person.revision)); } dossier=memoryRetainQuotations(dossier); const link=await env.DB.prepare('SELECT import_complete FROM memory_chat_links WHERE manager_id=? AND platform=? AND client_key=?').bind(identity.manager_id,identity.platform,clientKey).first(); const importComplete=payload.import_complete ? 1 : 0; const encrypted=await memoryEncrypt(env,identity.manager_id,person.person_id,'dossier','profile',dossier); const previous=await memoryDossier(env,identity,person); const changed=inserts.length>0 || updates.length>0 || Number(link.import_complete)!==importComplete || JSON.stringify(previous)!==JSON.stringify(dossier); if(!changed) return jsonResponse({ok:true,accepted:0,duplicates:messages.length,revision:person.revision},200,true); const statements=[...inserts,...updates,env.DB.prepare(`UPDATE memory_chat_links SET import_complete=? WHERE manager_id=? AND platform=? AND client_key=? AND EXISTS(SELECT 1 FROM memory_people WHERE manager_id=? AND person_id=? AND revision=?)`).bind(importComplete,identity.manager_id,identity.platform,clientKey,identity.manager_id,person.person_id,person.revision),env.DB.prepare(`UPDATE memory_people SET dossier_ciphertext=?,dossier_iv=?,dossier_key_version=?,revision=revision+1,updated_at=?,incomplete=CASE WHEN EXISTS(SELECT 1 FROM memory_chat_links WHERE manager_id=? AND person_id=? AND import_complete=0) THEN 1 ELSE 0 END WHERE manager_id=? AND person_id=? AND revision=?`).bind(...encrypted,Date.now(),identity.manager_id,person.person_id,identity.manager_id,person.person_id,person.revision)]; const results=await env.DB.batch(statements); if (!results[results.length-1].meta?.changes) { if(payload.dossier===undefined && attempt<3) return handleMemoryAppendConfirmed(env,identity,payload,attempt+1); throw new PublicError('MEMORY_REVISION_CONFLICT',409); } const accepted=results.slice(0,inserts.length+updates.length).reduce((n,r)=>n+Number(r.meta?.changes||0),0); return jsonResponse({ok:true,accepted,duplicates:messages.length-accepted,revision:person.revision+1},200,true); } async function handleMemoryUnlink(env, identity, payload) { const person = await memoryPersonByKey(env, identity, payload?.memory_key); const clientKey = memoryClientKey(payload); const linked = await memoryLinkForChat(env, identity, clientKey); if (!linked || linked.person_id !== person.person_id) throw new PublicError('MEMORY_LINK_CONFLICT', 409); const now = Date.now(); await env.DB.batch([ env.DB.prepare(`INSERT INTO memory_link_events(event_id,manager_id,person_id,platform,client_key,action,installation_id,created_at) SELECT ?,?,?,?,?,?,?,? WHERE EXISTS(SELECT 1 FROM memory_chat_links WHERE manager_id=? AND platform=? AND client_key=? AND person_id=?)`) .bind(crypto.randomUUID(),identity.manager_id,person.person_id,identity.platform,clientKey,'unlink',identity.installation_id,now,identity.manager_id,identity.platform,clientKey,person.person_id), env.DB.prepare('DELETE FROM memory_chat_links WHERE manager_id=? AND platform=? AND client_key=? AND person_id=?') .bind(identity.manager_id,identity.platform,clientKey,person.person_id), env.DB.prepare(`UPDATE memory_people SET revision=revision+1,updated_at=?, incomplete=CASE WHEN EXISTS(SELECT 1 FROM memory_chat_links WHERE manager_id=? AND person_id=? AND import_complete=0) THEN 1 ELSE 0 END WHERE manager_id=? AND person_id=?`).bind(now,identity.manager_id,person.person_id,identity.manager_id,person.person_id) ]); // An unlink removes routing only. The encrypted archive remains until verified deletion. return jsonResponse({ok:true},200,true); } async function handleMemoryDelete(env, identity, payload) { const person = await memoryPersonByKey(env, identity, payload?.memory_key); if (payload.confirm_memory_key !== person.memory_key) throw new PublicError('MEMORY_CONFIRMATION_REQUIRED',400); const linked = await env.DB.prepare('SELECT person_id FROM memory_chat_links WHERE manager_id=? AND person_id=? LIMIT 1').bind(identity.manager_id,person.person_id).first(); if (linked) throw new PublicError('MEMORY_STILL_LINKED',409); const guard = 'NOT EXISTS(SELECT 1 FROM memory_chat_links WHERE manager_id=? AND person_id=?)'; const results = await env.DB.batch([ env.DB.prepare(`INSERT INTO memory_link_events(event_id,manager_id,person_id,platform,client_key,action,installation_id,created_at) SELECT ?,?,?,?,?,?,?,? WHERE EXISTS(SELECT 1 FROM memory_people WHERE manager_id=? AND person_id=?) AND ${guard}`) .bind(crypto.randomUUID(),identity.manager_id,person.person_id,identity.platform,'','delete',identity.installation_id,Date.now(),identity.manager_id,person.person_id,identity.manager_id,person.person_id), env.DB.prepare(`DELETE FROM memory_messages WHERE manager_id=? AND person_id=? AND ${guard}`).bind(identity.manager_id,person.person_id,identity.manager_id,person.person_id), env.DB.prepare(`DELETE FROM memory_people WHERE manager_id=? AND person_id=? AND ${guard}`).bind(identity.manager_id,person.person_id,identity.manager_id,person.person_id) ]); if (!results[2]?.meta?.changes) throw new PublicError('MEMORY_DELETE_CONFLICT',409); return jsonResponse({ok:true},200,true); } async function handleMemoryPreview(env, identity, payload) { const person = await memoryPersonByKey(env, identity, payload?.memory_key); const links = await env.DB.prepare('SELECT DISTINCT platform FROM memory_chat_links WHERE manager_id = ? AND person_id = ? ORDER BY platform') .bind(identity.manager_id, person.person_id).all(); return jsonResponse({ ok: true, memory_key: person.memory_key, summary: (await memoryDossier(env, identity, person)).map(entry => entry.text).join(' · ').slice(0, 600), platforms: (links.results || []).map(row => row.platform), incomplete: Boolean(person.incomplete) }, 200, true); } function memoryProfileText(profile) { if (!profile || typeof profile !== 'object' || Array.isArray(profile)) throw new PublicError('MEMORY_PROFILE_INVALID',400); const text = Object.entries(profile).filter(([k,v]) => typeof v==='string' && v.trim()).map(([k,v])=>`${k}: ${v.trim()}`).join('\n'); if (new TextEncoder().encode(text).length>16000) throw new PublicError('MEMORY_PROFILE_TOO_LARGE',413); return text; } async function handleMemoryCreateOrAttach(env, identity, payload, attempt=0) { assertMemoryIdentity(identity); const clientKey = memoryClientKey(payload); // Validate a supplied key before considering an existing chat: never silently replace it. const supplied = payload?.memory_key; const target = supplied === undefined || supplied === '' || supplied === null ? null : await memoryPersonByKey(env, identity, supplied); const existing = await memoryLinkForChat(env, identity, clientKey); if (existing) { if (target && existing.person_id !== target.person_id) throw new PublicError('MEMORY_CHAT_ALREADY_LINKED', 409); if (payload.profile !== undefined) { const text = memoryProfileText(payload.profile); const previous = await memoryDossier(env, identity, existing); const ownProfiles = previous.filter(entry => entry.source==='manager_profile' && entry.source_platform===identity.platform); if (ownProfiles.length !== (text ? 1 : 0) || (text && ownProfiles[0].text !== text)) { const now = Date.now(); const dossier = previous.filter(entry => entry.source!=='manager_profile' || entry.source_platform!==identity.platform); if (text) dossier.push({text,source_platform:identity.platform,source:'manager_profile',updated_at:now}); const encrypted = await memoryEncrypt(env,identity.manager_id,existing.person_id,'dossier','profile',dossier); const result = await env.DB.prepare(`UPDATE memory_people SET dossier_ciphertext=?,dossier_iv=?,dossier_key_version=?,revision=revision+1,updated_at=? WHERE manager_id=? AND person_id=? AND revision=?`) .bind(...encrypted,now,identity.manager_id,existing.person_id,existing.revision).run(); if (!result?.meta?.changes) { if (attempt<3) return handleMemoryCreateOrAttach(env,identity,payload,attempt+1); throw new PublicError('MEMORY_REVISION_CONFLICT',409); } } } return jsonResponse({ ok: true, person_id: existing.person_id, memory_key: existing.memory_key, link_state: 'linked', created: false }, 200, true); } const personId = target?.person_id || crypto.randomUUID(); const memoryKey = target?.memory_key || bytesToBase64Url(crypto.getRandomValues(new Uint8Array(32))); const now = Date.now(); const statements = []; let seeded = ['', '', 0]; if (!target) { const text = payload.profile === undefined ? '' : memoryProfileText(payload.profile); seeded = await memoryEncrypt(env,identity.manager_id,personId,'dossier','profile',text ? [{text,source_platform:identity.platform,source:'manager_profile',updated_at:now}] : []); } if (!target) statements.push(env.DB.prepare(`INSERT INTO memory_people (person_id,manager_id,memory_key,created_at,updated_at,dossier_ciphertext,dossier_iv,dossier_key_version,revision) SELECT ?,?,?,?,?,?,?,?,1 WHERE NOT EXISTS (SELECT 1 FROM memory_chat_links WHERE manager_id=? AND platform=? AND client_key=?)`) .bind(personId,identity.manager_id,memoryKey,now,now,...seeded,identity.manager_id,identity.platform,clientKey)); // Audit the missing-to-linked transition before inserting the link. Both // statements share the transaction, so retries cannot add another audit row. statements.push(env.DB.prepare(`INSERT INTO memory_link_events (event_id,manager_id,person_id,platform,client_key,action,installation_id,created_at) SELECT ?,?,?,?,?,?,?,? WHERE EXISTS (SELECT 1 FROM memory_people WHERE manager_id=? AND person_id=?) AND NOT EXISTS (SELECT 1 FROM memory_chat_links WHERE manager_id=? AND platform=? AND client_key=?)`) .bind(crypto.randomUUID(),identity.manager_id,personId,identity.platform,clientKey,'attach',identity.installation_id,now, identity.manager_id,personId,identity.manager_id,identity.platform,clientKey)); statements.push(env.DB.prepare(`INSERT OR IGNORE INTO memory_chat_links (manager_id,platform,client_key,person_id,linked_at,source) SELECT ?,?,?,?,?,? FROM memory_people WHERE manager_id=? AND person_id=?`) .bind(identity.manager_id,identity.platform,clientKey,personId,now,'manager_profile',identity.manager_id,personId)); if (target) statements.push(env.DB.prepare(`UPDATE memory_people SET incomplete=1,revision=revision+1,updated_at=? WHERE manager_id=? AND person_id=? AND EXISTS(SELECT 1 FROM memory_chat_links WHERE manager_id=? AND person_id=? AND import_complete=0)`).bind(now,identity.manager_id,personId,identity.manager_id,personId)); // D1 batch is transactional: the existence check, insert and link cannot interleave. await env.DB.batch(statements); const linked = await memoryLinkForChat(env, identity, clientKey); if (!linked) throw new PublicError('MEMORY_UNAVAILABLE', 503); if (target && linked.person_id !== target.person_id) throw new PublicError('MEMORY_CHAT_ALREADY_LINKED', 409); // A newly attached chat also contributes its manager-entered profile. // Reuse the existing-link CAS path so retries cannot duplicate it. if (target && payload.profile !== undefined) { return handleMemoryCreateOrAttach(env, identity, payload, attempt); } return jsonResponse({ ok: true, person_id: linked.person_id, memory_key: linked.memory_key, link_state: 'linked', created: !target && linked.person_id === personId }, 200, true); } async function handleMaxBackupSave(env, identity, payload) { const recordKey = String(payload?.record_key || '').trim(); if (!/^(?:__roster__|client:[^\x00-\x1f]{1,150})$/.test(recordKey) || !payload?.value || typeof payload.value !== 'object' || Array.isArray(payload.value)) { throw new PublicError('BACKUP_RECORD_INVALID', 400); } const expectedRevision = Number(payload?.expected_revision); if (!Number.isSafeInteger(expectedRevision) || expectedRevision < 0) { throw new PublicError('BACKUP_REVISION_REQUIRED', 400); } const plaintext = new TextEncoder().encode(JSON.stringify(payload.value)); if (plaintext.length > MAX_BACKUP_RECORD_BYTES) { throw new PublicError('BACKUP_RECORD_TOO_LARGE', 413); } const key = await importDataEncryptionKey(env); const iv = crypto.getRandomValues(new Uint8Array(12)); const ciphertext = await crypto.subtle.encrypt( { name: 'AES-GCM', iv, additionalData: maxBackupAad(identity, recordKey), tagLength: 128 }, key, plaintext ); const savedAt = Math.max(Date.now(), expectedRevision + 1); const common = [ bytesToBase64Url(new Uint8Array(ciphertext)), bytesToBase64Url(iv), DATA_ENCRYPTION_KEY_VERSION, savedAt, ]; const result = expectedRevision === 0 ? await env.DB.prepare(` INSERT INTO max_stage_backups (manager_id, platform, record_key, ciphertext, iv, key_version, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(manager_id, platform, record_key) DO NOTHING `).bind(identity.manager_id, identity.platform, recordKey, ...common).run() : await env.DB.prepare(` UPDATE max_stage_backups SET ciphertext = ?, iv = ?, key_version = ?, updated_at = ? WHERE manager_id = ? AND platform = ? AND record_key = ? AND updated_at = ? `).bind(...common, identity.manager_id, identity.platform, recordKey, expectedRevision).run(); if (Number(result?.meta?.changes || 0) !== 1) { throw new PublicError('BACKUP_REVISION_CONFLICT', 409); } return jsonResponse({ ok: true, record_key: recordKey, saved_at: savedAt }, 200, true); } async function handleMaxBackupLoad(env, identity, payload) { const cursor = String(payload?.cursor || '').trim(); if (cursor.length > 160) throw new PublicError('BACKUP_CURSOR_INVALID', 400); const rows = await env.DB.prepare(` SELECT record_key, ciphertext, iv, key_version, updated_at FROM max_stage_backups WHERE manager_id = ? AND platform = ? AND record_key > ? ORDER BY record_key LIMIT 11 `).bind(identity.manager_id, identity.platform, cursor).all(); const page = (rows.results || []).slice(0, 10); const key = page.length ? await importDataEncryptionKey(env) : null; const records = []; for (const row of page) { try { if (Number(row.key_version) !== DATA_ENCRYPTION_KEY_VERSION) { throw new Error('UNSUPPORTED_KEY_VERSION'); } const bytes = await crypto.subtle.decrypt( { name: 'AES-GCM', iv: base64UrlToBytes(row.iv), additionalData: maxBackupAad(identity, row.record_key), tagLength: 128, }, key, base64UrlToBytes(row.ciphertext) ); records.push({ record_key: row.record_key, value: JSON.parse(new TextDecoder().decode(bytes)), saved_at: Number(row.updated_at || 0), }); } catch (_) { throw new PublicError('BACKUP_DECRYPT_FAILED', 503); } } return jsonResponse({ ok: true, records, next_cursor: (rows.results || []).length > 10 ? page.at(-1).record_key : '', }, 200, true); } async function copyStageBackupsForStageMerge(env, sourceManagerId, targetManagerId) { if (!sourceManagerId || sourceManagerId === targetManagerId) return; const rows = await env.DB.prepare(` SELECT platform, record_key, ciphertext, iv, key_version, updated_at FROM max_stage_backups WHERE manager_id = ? AND platform IN ('max', 'telegram', 'whatsapp', 'gemspace') `).bind(sourceManagerId).all(); if (!(rows.results || []).length) return; const key = await importDataEncryptionKey(env); for (const row of rows.results) { const source = { manager_id: sourceManagerId, platform: row.platform }; const target = { manager_id: targetManagerId, platform: row.platform }; let plaintext; let savedAt = Number(row.updated_at || 0); try { if (Number(row.key_version) !== DATA_ENCRYPTION_KEY_VERSION) { throw new Error('UNSUPPORTED_KEY_VERSION'); } plaintext = await crypto.subtle.decrypt( { name: 'AES-GCM', iv: base64UrlToBytes(row.iv), additionalData: maxBackupAad(source, row.record_key), tagLength: 128 }, key, base64UrlToBytes(row.ciphertext) ); } catch (_) { throw new PublicError('BACKUP_DECRYPT_FAILED', 503); } if (row.record_key === '__roster__') { const existing = await env.DB.prepare(` SELECT ciphertext, iv, key_version, updated_at FROM max_stage_backups WHERE manager_id = ? AND platform = ? AND record_key = '__roster__' `).bind(targetManagerId, row.platform).first(); if (existing) { let targetRoster; let sourceRoster; try { if (Number(existing.key_version) !== DATA_ENCRYPTION_KEY_VERSION) { throw new Error('UNSUPPORTED_KEY_VERSION'); } const targetPlaintext = await crypto.subtle.decrypt( { name: 'AES-GCM', iv: base64UrlToBytes(existing.iv), additionalData: maxBackupAad(target, '__roster__'), tagLength: 128 }, key, base64UrlToBytes(existing.ciphertext) ); targetRoster = JSON.parse(new TextDecoder().decode(targetPlaintext)); sourceRoster = JSON.parse(new TextDecoder().decode(plaintext)); } catch (_) { throw new PublicError('BACKUP_DECRYPT_FAILED', 503); } const sourceNewer = savedAt > Number(existing.updated_at || 0); const older = sourceNewer ? targetRoster : sourceRoster; const newer = sourceNewer ? sourceRoster : targetRoster; const clients = new Map(); for (const client of [...(older.clients || []), ...(newer.clients || [])]) { if (client?.id) clients.set(String(client.id), client); } const merged = { schema: 1, clients: [...clients.values()], removedClients: { ...(older.removedClients || {}), ...(newer.removedClients || {}) }, identities: { ...(older.identities || {}), ...(newer.identities || {}) }, parkPeerId: newer.parkPeerId || older.parkPeerId || '', parkName: newer.parkName || older.parkName || '', }; merged.clients = merged.clients.filter((client) => { const removed = merged.removedClients[String(client.id)]; if (!removed) return true; if (Number(client.addedAt || 0) > Number(removed.removedAt || 0)) { delete merged.removedClients[String(client.id)]; return true; } return false; }); for (const removedId of Object.keys(merged.removedClients)) { delete merged.identities[removedId]; } plaintext = new TextEncoder().encode(JSON.stringify(merged)); if (plaintext.length > MAX_BACKUP_RECORD_BYTES) { throw new PublicError('BACKUP_RECORD_TOO_LARGE', 413); } savedAt = Math.max(Date.now(), savedAt + 1, Number(existing.updated_at || 0) + 1); } } const iv = crypto.getRandomValues(new Uint8Array(12)); const ciphertext = await crypto.subtle.encrypt( { name: 'AES-GCM', iv, additionalData: maxBackupAad(target, row.record_key), tagLength: 128 }, key, plaintext ); await env.DB.prepare(` INSERT INTO max_stage_backups (manager_id, platform, record_key, ciphertext, iv, key_version, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(manager_id, platform, record_key) DO UPDATE SET ciphertext = excluded.ciphertext, iv = excluded.iv, key_version = excluded.key_version, updated_at = excluded.updated_at WHERE excluded.updated_at > max_stage_backups.updated_at `).bind( targetManagerId, row.platform, row.record_key, bytesToBase64Url(new Uint8Array(ciphertext)), bytesToBase64Url(iv), DATA_ENCRYPTION_KEY_VERSION, savedAt ).run(); } } async function encryptDecisionSensitive(env, requestId, payload) { const key = await importDataEncryptionKey(env); const iv = crypto.getRandomValues(new Uint8Array(12)); const plaintext = new TextEncoder().encode(JSON.stringify(payload)); const ciphertext = await crypto.subtle.encrypt( { name: 'AES-GCM', iv, additionalData: decisionAad(requestId), tagLength: 128, }, key, plaintext ); return { sensitive_ciphertext: bytesToBase64Url(new Uint8Array(ciphertext)), sensitive_iv: bytesToBase64Url(iv), sensitive_key_version: DATA_ENCRYPTION_KEY_VERSION, }; } async function decryptDecisionSensitive(env, row, strict = false) { if (!row?.sensitive_ciphertext) { return { reason: row?.reason || '', manager_task: row?.manager_task || '', recommendation: row?.recommendation || '', context: parseDecisionContext(row?.context_json), resolution_text: row?.resolution_text || '', }; } try { const keyVersion = Number(row.sensitive_key_version || 0); if (keyVersion !== DATA_ENCRYPTION_KEY_VERSION) { throw new Error('UNSUPPORTED_KEY_VERSION'); } const key = await importDataEncryptionKey(env); const plaintext = await crypto.subtle.decrypt( { name: 'AES-GCM', iv: base64UrlToBytes(row.sensitive_iv), additionalData: decisionAad(row.request_id, keyVersion), tagLength: 128, }, key, base64UrlToBytes(row.sensitive_ciphertext) ); const parsed = JSON.parse(new TextDecoder().decode(plaintext)); return { reason: cleanDecisionText(parsed?.reason, 1200), manager_task: cleanDecisionText(parsed?.manager_task, 1200), recommendation: cleanDecisionText(parsed?.recommendation, 2200), context: normalizeDecisionContext(parsed?.context), resolution_text: cleanDecisionText(parsed?.resolution_text, 2200), }; } catch (error) { console.error('decision_decryption_failed', { request_id: cleanText(row?.request_id, 160), name: cleanText(error?.name || 'Error', 80), message: redactSensitive(error?.message || String(error), 300), }); if (strict) throw new PublicError('DECISION_DECRYPT_FAILED', 500); return { reason: '[зашифрованные данные недоступны]', manager_task: '', recommendation: '', context: [], resolution_text: '', decryption_error: true, }; } } async function hydrateDecisionRow(env, row, strict = false) { if (!row) return row; const sensitive = await decryptDecisionSensitive(env, row, strict); return { ...row, reason: sensitive.reason, manager_task: sensitive.manager_task, recommendation: sensitive.recommendation, context_json: JSON.stringify(sensitive.context || []), resolution_text: sensitive.resolution_text, decryption_error: Boolean(sensitive.decryption_error), }; } function publicDecisionRow(row, includeContext = true) { const output = { ...row }; if (includeContext) { output.context = parseDecisionContext(output.context_json); } delete output.context_json; delete output.sensitive_ciphertext; delete output.sensitive_iv; delete output.lease_token_hash; return output; } async function decisionRowForUser(env, row, user, includeContext = true) { const hydrated = await hydrateDecisionRow(env, row); const output = publicDecisionRow(hydrated, includeContext); if (!canSeeErrors(user)) { delete output.trace_id; delete output.execution_error; delete output.delivery_attempts; delete output.delivered_at; delete output.source; } return output; } async function recordDecisionEvent( env, requestId, eventType, actor = null, details = {} ) { await env.DB.prepare(` INSERT INTO decision_events ( request_id, at, event_type, actor_user_id, actor_login, details_json ) VALUES (?, ?, ?, ?, ?, ?) `).bind( cleanText(requestId, 160), Date.now(), cleanCode(eventType, 80), Number(actor?.id || 0), cleanText(actor?.login, 80), safeDetailsJson(details) ).run(); } async function expireDecisionRequests(env, now = Date.now()) { await env.DB.prepare(` UPDATE decision_requests SET status = 'expired', updated_at = ?, execution_status = 'expired' WHERE status = 'pending' AND expires_at < ? `).bind(now, now).run(); await env.DB.prepare(` UPDATE decision_requests SET status = 'failed', updated_at = ?, execution_status = 'delivery_timeout', execution_error = 'Скрипт не забрал решение вовремя', lease_owner = '', lease_token_hash = '', lease_expires_at = 0 WHERE status = 'resolved' AND expires_at < ? `).bind(now, now).run(); await env.DB.prepare(` UPDATE decision_requests SET status = 'failed', updated_at = ?, executed_at = ?, execution_status = 'attempts_exhausted', execution_error = CASE WHEN execution_error = '' THEN 'Лимит попыток выполнения исчерпан' ELSE execution_error END, lease_owner = '', lease_token_hash = '', lease_expires_at = 0 WHERE status = 'resolved' AND execution_attempts >= ? AND lease_expires_at > 0 AND lease_expires_at < ? `).bind( now, now, DECISION_MAX_EXECUTION_ATTEMPTS, now ).run(); } async function createDecisionRequest(env, payload, source = 'script') { const now = Date.now(); await expireDecisionRequests(env, now); const installationId = cleanText( payload?.installation_id || payload?.installationId, 160 ); const clientKey = cleanText( payload?.client_key || payload?.clientKey, 160 ); const typeCandidate = cleanText( payload?.decision_type || payload?.type || payload?.human_type, 30 ).toLowerCase(); const decisionType = typeCandidate === 'call_decision' ? 'call' : typeCandidate; if (!installationId) { throw new PublicError('INSTALLATION_ID_REQUIRED', 400); } if (!clientKey) { throw new PublicError('CLIENT_KEY_REQUIRED', 400); } if (!DECISION_TYPE_VALUES.has(decisionType)) { throw new PublicError('DECISION_TYPE_INVALID', 400); } const [installation, client] = await Promise.all([ env.DB.prepare(` SELECT manager_id, manager_name, stage, platform FROM installations WHERE installation_id = ? `).bind(installationId).first(), env.DB.prepare(` SELECT client_label, platform FROM client_states WHERE installation_id = ? AND client_key = ? `).bind(installationId, clientKey).first(), ]); const requestId = cleanText(payload?.request_id, 160) || crypto.randomUUID(); const requestedExpiry = safeTimestamp(payload?.expires_at); const expiresAt = requestedExpiry > now + 5 * 60 * 1000 && requestedExpiry <= now + 24 * 60 * 60 * 1000 ? requestedExpiry : now + DECISION_TTL_MS; const context = normalizeDecisionContext( payload?.context || payload?.messages ); const proposedTimeAt = safeTimestamp( payload?.proposed_time_at || payload?.call_time_at ); const data = { request_id: requestId, installation_id: installationId, client_key: clientKey, client_label: cleanText( client?.client_label || payload?.client_label || payload?.client_name, 120 ), manager_id: cleanText( installation?.manager_id || payload?.manager_id, 120 ), manager_name: cleanText( installation?.manager_name || payload?.manager_name, 120 ), stage: cleanText(installation?.stage || payload?.stage, 120), platform: cleanText( installation?.platform || client?.platform || payload?.platform, 40 ).toLowerCase(), decision_type: decisionType, reason: redactSensitive( payload?.reason || payload?.manager_task || 'Требуется решение', 1200 ), manager_task: redactSensitive(payload?.manager_task, 1200), recommendation: redactSensitive( payload?.recommendation || payload?.draft || payload?.message, 2200 ), context_json: JSON.stringify(context), proposed_time_at: proposedTimeAt, proposed_time_text: cleanText( payload?.proposed_time_text || payload?.call_time_text, 200 ), trace_id: cleanText(payload?.trace_id, 160), source: cleanCode(source, 40) || 'script', created_at: now, updated_at: now, expires_at: expiresAt, }; const encrypted = await encryptDecisionSensitive(env, requestId, { reason: data.reason, manager_task: data.manager_task, recommendation: data.recommendation, context, resolution_text: '', }); const sameRequest = await env.DB.prepare(` SELECT * FROM decision_requests WHERE request_id = ? `).bind(requestId).first(); if (sameRequest) { const hydrated = await hydrateDecisionRow(env, sameRequest); return { row: publicDecisionRow(hydrated), created: false, deduplicated: true, }; } const openRequest = await env.DB.prepare(` SELECT * FROM decision_requests WHERE installation_id = ? AND client_key = ? AND decision_type = ? AND status IN ('pending', 'resolved') ORDER BY created_at DESC LIMIT 1 `).bind( installationId, clientKey, decisionType ).first(); if (openRequest) { const hydrated = await hydrateDecisionRow(env, openRequest); return { row: publicDecisionRow(hydrated), created: false, deduplicated: true, }; } const inserted = await env.DB.prepare(` INSERT OR IGNORE INTO decision_requests ( request_id, installation_id, client_key, client_label, manager_id, manager_name, stage, platform, decision_type, status, reason, manager_task, recommendation, context_json, proposed_time_at, proposed_time_text, trace_id, source, created_at, updated_at, expires_at, sensitive_ciphertext, sensitive_iv, sensitive_key_version, next_attempt_at ) VALUES ( ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ) `).bind( data.request_id, data.installation_id, data.client_key, data.client_label, data.manager_id, data.manager_name, data.stage, data.platform, data.decision_type, '', '', '', '[]', data.proposed_time_at, data.proposed_time_text, data.trace_id, data.source, data.created_at, data.updated_at, data.expires_at, encrypted.sensitive_ciphertext, encrypted.sensitive_iv, encrypted.sensitive_key_version, now ).run(); let row = await env.DB.prepare(` SELECT * FROM decision_requests WHERE request_id = ? `).bind(requestId).first(); if (!row && !decisionChanges(inserted)) { row = await env.DB.prepare(` SELECT * FROM decision_requests WHERE installation_id = ? AND client_key = ? AND decision_type = ? AND status IN ('pending', 'resolved') ORDER BY created_at DESC LIMIT 1 `).bind(installationId, clientKey, decisionType).first(); } if (!row) { throw new PublicError('DECISION_CREATE_FAILED', 500); } const created = row.request_id === requestId; if (created) { await recordDecisionEvent( env, requestId, 'created', null, { type: decisionType, source: data.source } ); await env.DB.prepare(` UPDATE client_states SET awaiting_human = 1, status = 'human', updated_at = ? WHERE installation_id = ? AND client_key = ? `).bind(now, installationId, clientKey).run(); } const hydratedRow = await hydrateDecisionRow(env, row); return { row: publicDecisionRow(hydratedRow), created, deduplicated: !created, }; } async function handleDecisionRequest(env, payload, installationAuth) { const result = await createDecisionRequest( env, bindDecisionIdentity(payload, installationAuth), 'script' ); return jsonResponse({ ok: true, accepted: 'decision_request', request_id: result.row.request_id, status: result.row.status, created: result.created, deduplicated: result.deduplicated, expires_at: result.row.expires_at, server_time: Date.now(), }, 200, true); } async function handleDecisionPoll(env, payload, installationAuth) { const now = Date.now(); await expireDecisionRequests(env, now); const installationId = installationAuth.installation_id; const instanceId = cleanText( payload?.instance_id || payload?.tab_id || payload?.worker_id, 160 ); const requestId = cleanText(payload?.request_id, 160); const clientKey = cleanText(payload?.client_key, 160); if (!instanceId) throw new PublicError('INSTANCE_ID_REQUIRED', 400); let sql = ` SELECT * FROM decision_requests WHERE installation_id = ? AND status = 'resolved' AND execution_attempts < ? AND next_attempt_at <= ? AND (lease_expires_at = 0 OR lease_expires_at <= ?) `; const params = [ installationId, DECISION_MAX_EXECUTION_ATTEMPTS, now, now, ]; if (requestId) { sql += ' AND request_id = ?'; params.push(requestId); } if (clientKey) { sql += ' AND client_key = ?'; params.push(clientKey); } sql += ' ORDER BY resolved_at ASC LIMIT 10'; const candidates = (await env.DB.prepare(sql).bind(...params).all()).results || []; const decisions = []; for (const candidate of candidates) { const leaseToken = randomOpaqueSecret(32); const leaseTokenHash = await sha256Hex(leaseToken); const leaseExpiresAt = Math.min( now + DECISION_LEASE_MS, Number(candidate.expires_at || now + DECISION_LEASE_MS) ); const claimed = await env.DB.prepare(` UPDATE decision_requests SET lease_owner = ?, lease_token_hash = ?, lease_expires_at = ?, execution_attempts = execution_attempts + 1, delivered_at = CASE WHEN delivered_at = 0 THEN ? ELSE delivered_at END, delivery_attempts = delivery_attempts + 1, execution_status = 'leased', updated_at = ? WHERE request_id = ? AND installation_id = ? AND status = 'resolved' AND execution_attempts < ? AND next_attempt_at <= ? AND (lease_expires_at = 0 OR lease_expires_at <= ?) `).bind( instanceId, leaseTokenHash, leaseExpiresAt, now, now, candidate.request_id, installationId, DECISION_MAX_EXECUTION_ATTEMPTS, now, now ).run(); if (!decisionChanges(claimed)) continue; const claimedRow = await env.DB.prepare(` SELECT * FROM decision_requests WHERE request_id = ? AND installation_id = ? `).bind(candidate.request_id, installationId).first(); const hydrated = await hydrateDecisionRow(env, claimedRow, true); decisions.push({ request_id: hydrated.request_id, client_key: hydrated.client_key, client_label: hydrated.client_label, decision_type: hydrated.decision_type, action: hydrated.resolution_action, instruction: hydrated.resolution_text, time_at: numberOrZero(hydrated.resolution_time_at), resolved_at: numberOrZero(hydrated.resolved_at), resolved_by: hydrated.resolved_login, trace_id: hydrated.trace_id, lease_token: leaseToken, lease_expires_at: leaseExpiresAt, execution_attempt: numberOrZero(hydrated.execution_attempts), max_execution_attempts: DECISION_MAX_EXECUTION_ATTEMPTS, prepared_message_hash: hydrated.prepared_message_hash || '', }); await recordDecisionEvent(env, candidate.request_id, 'leased', null, { instance_id: instanceId, execution_attempt: numberOrZero(hydrated.execution_attempts), lease_expires_at: leaseExpiresAt, }); } return jsonResponse({ ok: true, decisions, lease_ttl_ms: DECISION_LEASE_MS, renew_after_ms: 30 * 1000, server_time: now, }, 200, true); } async function getDecisionWithValidLease( env, payload, installationAuth, requireUnexpired = true ) { const now = Date.now(); const requestId = cleanText(payload?.request_id, 160); const instanceId = cleanText( payload?.instance_id || payload?.tab_id || payload?.worker_id, 160 ); const leaseToken = cleanText(payload?.lease_token, 300); if (!requestId) throw new PublicError('REQUEST_ID_REQUIRED', 400); if (!instanceId) throw new PublicError('INSTANCE_ID_REQUIRED', 400); if (!leaseToken) throw new PublicError('LEASE_TOKEN_REQUIRED', 400); const row = await env.DB.prepare(` SELECT * FROM decision_requests WHERE request_id = ? AND installation_id = ? `).bind(requestId, installationAuth.installation_id).first(); if (!row) throw new PublicError('DECISION_NOT_FOUND', 404); if (row.status !== 'resolved') { if (['executed', 'failed'].includes(row.status)) return row; throw new PublicError('DECISION_NOT_READY', 409); } if (!safeEqual(row.lease_owner, instanceId)) { throw new PublicError('LEASE_OWNER_MISMATCH', 409); } const leaseHash = await sha256Hex(leaseToken); if (!safeEqual(row.lease_token_hash, leaseHash)) { throw new PublicError('LEASE_TOKEN_INVALID', 403); } if (requireUnexpired && Number(row.lease_expires_at || 0) < now) { throw new PublicError('LEASE_EXPIRED', 409); } return row; } async function handleDecisionLeaseRenew(env, payload, installationAuth) { const now = Date.now(); const row = await getDecisionWithValidLease( env, payload, installationAuth, true ); if (row.status !== 'resolved') { throw new PublicError('DECISION_ALREADY_PROCESSED', 409); } const leaseExpiresAt = Math.min( now + DECISION_LEASE_MS, Number(row.expires_at || now + DECISION_LEASE_MS) ); const updated = await env.DB.prepare(` UPDATE decision_requests SET lease_expires_at = ?, updated_at = ? WHERE request_id = ? AND installation_id = ? AND status = 'resolved' AND lease_owner = ? AND lease_token_hash = ? AND lease_expires_at >= ? `).bind( leaseExpiresAt, now, row.request_id, installationAuth.installation_id, row.lease_owner, row.lease_token_hash, now ).run(); if (!decisionChanges(updated)) throw new PublicError('LEASE_LOST', 409); return jsonResponse({ ok: true, request_id: row.request_id, lease_expires_at: leaseExpiresAt, renew_after_ms: 30 * 1000, server_time: now, }, 200, true); } async function handleDecisionPrepared(env, payload, installationAuth) { const now = Date.now(); const row = await getDecisionWithValidLease( env, payload, installationAuth, true ); const messageHash = cleanText(payload?.message_hash, 128).toLowerCase(); if (!/^[a-f0-9]{64}$/.test(messageHash)) { throw new PublicError('MESSAGE_HASH_INVALID', 400); } const updated = await env.DB.prepare(` UPDATE decision_requests SET prepared_message_hash = ?, prepared_at = ?, updated_at = ? WHERE request_id = ? AND installation_id = ? AND status = 'resolved' AND lease_owner = ? AND lease_token_hash = ? AND lease_expires_at >= ? `).bind( messageHash, now, now, row.request_id, installationAuth.installation_id, row.lease_owner, row.lease_token_hash, now ).run(); if (!decisionChanges(updated)) throw new PublicError('LEASE_LOST', 409); await recordDecisionEvent(env, row.request_id, 'message_prepared', null, { message_hash: messageHash, execution_attempt: numberOrZero(row.execution_attempts), }); return jsonResponse({ ok: true, request_id: row.request_id, prepared_at: now, server_time: now, }, 200, true); } async function handleDecisionAck(env, payload, installationAuth) { const now = Date.now(); const requestId = cleanText(payload?.request_id, 160); if (!requestId) throw new PublicError('REQUEST_ID_REQUIRED', 400); const existing = await env.DB.prepare(` SELECT * FROM decision_requests WHERE request_id = ? AND installation_id = ? `).bind(requestId, installationAuth.installation_id).first(); if (!existing) throw new PublicError('DECISION_NOT_FOUND', 404); if (['executed', 'failed'].includes(existing.status)) { return jsonResponse({ ok: true, request_id: requestId, status: existing.status, idempotent: true, server_time: now, }, 200, true); } const current = await getDecisionWithValidLease( env, payload, installationAuth, true ); const rawStatus = cleanText(payload?.status, 40).toLowerCase(); const outcome = rawStatus || (payload?.success === false ? 'terminal_error' : 'executed'); if (!['executed', 'retryable_error', 'terminal_error'].includes(outcome)) { throw new PublicError('ACK_STATUS_INVALID', 400); } const errorText = redactSensitive( payload?.error || payload?.message, 700 ); const attempts = numberOrZero(current.execution_attempts); const canRetry = outcome === 'retryable_error' && attempts < DECISION_MAX_EXECUTION_ATTEMPTS; const paused = current.resolution_action === 'pause'; const nextStatus = outcome === 'executed' ? 'executed' : (canRetry ? 'resolved' : 'failed'); const retryDelay = canRetry ? DECISION_RETRY_DELAYS_MS[Math.min( Math.max(attempts - 1, 0), DECISION_RETRY_DELAYS_MS.length - 1 )] : 0; const nextAttemptAt = canRetry ? now + retryDelay : 0; const executionStatus = outcome === 'executed' ? 'success' : (canRetry ? 'retry_scheduled' : 'terminal_error'); const updated = await env.DB.prepare(` UPDATE decision_requests SET status = ?, executed_at = ?, execution_status = ?, execution_error = ?, next_attempt_at = ?, lease_owner = '', lease_token_hash = '', lease_expires_at = 0, updated_at = ? WHERE request_id = ? AND installation_id = ? AND status = 'resolved' AND lease_owner = ? AND lease_token_hash = ? AND lease_expires_at >= ? `).bind( nextStatus, outcome === 'executed' || !canRetry ? now : 0, executionStatus, outcome === 'executed' ? '' : (errorText || 'Ошибка выполнения решения'), nextAttemptAt, now, requestId, installationAuth.installation_id, current.lease_owner, current.lease_token_hash, now ).run(); if (!decisionChanges(updated)) throw new PublicError('LEASE_LOST', 409); await recordDecisionEvent( env, requestId, outcome === 'executed' ? 'executed' : (canRetry ? 'execution_retry_scheduled' : 'execution_failed'), null, { error: outcome === 'executed' ? '' : errorText, execution_attempt: attempts, next_attempt_at: nextAttemptAt, } ); await env.DB.prepare(` UPDATE client_states SET awaiting_human = ?, status = ?, last_success_at = CASE WHEN ? = 1 THEN ? ELSE last_success_at END, last_error_code = CASE WHEN ? = 1 THEN '' WHEN ? = 1 THEN 'DECISION_RETRY_SCHEDULED' ELSE 'DECISION_EXECUTION_FAILED' END, updated_at = ? WHERE installation_id = ? AND client_key = ? `).bind( outcome === 'executed' && !paused ? 0 : 1, outcome === 'executed' ? (paused ? 'paused' : 'waiting_client') : (canRetry ? 'human' : 'error'), outcome === 'executed' ? 1 : 0, now, outcome === 'executed' ? 1 : 0, canRetry ? 1 : 0, now, installationAuth.installation_id, current.client_key ).run(); return jsonResponse({ ok: true, request_id: requestId, status: nextStatus, retry_scheduled: canRetry, next_attempt_at: nextAttemptAt, attempts, max_attempts: DECISION_MAX_EXECUTION_ATTEMPTS, server_time: now, }, 200, true); } async function handleInstallationEnrollment(request, env) { await enforceRateLimit( env.ENROLL_RATE_LIMITER, `enroll:${request.headers.get('CF-Connecting-IP') || 'unknown'}`, 'ENROLL_RATE_LIMITED' ); const payload = await readJsonBody(request); const activationCode = cleanText( payload?.activation_code || payload?.code, 400 ); const parsed = parseScopedToken(activationCode, ACTIVATION_CODE_PREFIX); if (!parsed) throw new PublicError('ACTIVATION_CODE_INVALID', 400); const now = Date.now(); const codeRow = await env.DB.prepare(` SELECT * FROM enrollment_codes WHERE code_id = ? `).bind(parsed.id).first(); if (!codeRow || codeRow.status !== 'active') { throw new PublicError('ACTIVATION_CODE_INVALID', 403); } if (Number(codeRow.expires_at || 0) < now) { await env.DB.prepare(` UPDATE enrollment_codes SET status = 'expired' WHERE code_id = ? AND status = 'active' `).bind(codeRow.code_id).run(); throw new PublicError('ACTIVATION_CODE_EXPIRED', 410); } const providedHash = await sha256Hex(parsed.token); if (!safeEqual(providedHash, codeRow.code_hash)) { throw new PublicError('ACTIVATION_CODE_INVALID', 403); } // Новые скрипты всегда сообщают фактическую платформу. Код, созданный // для Telegram, нельзя случайно или намеренно активировать в другом мессенджере. // Отсутствие поля пока разрешено для плавного обновления старых скриптов. const requestedPlatform = cleanText( payload?.platform, 40 ).toLowerCase(); if ( requestedPlatform && !['max', 'telegram', 'whatsapp', 'gemspace'].includes(requestedPlatform) ) { throw new PublicError('PLATFORM_INVALID', 400); } if ( requestedPlatform && codeRow.platform !== 'unknown' && requestedPlatform !== codeRow.platform ) { throw new PublicError('ACTIVATION_PLATFORM_MISMATCH', 409); } const installationId = cleanText( payload?.installation_id || payload?.installationId, 160 ) || crypto.randomUUID(); const existingCredential = await env.DB.prepare(` SELECT credential_id, status FROM installation_credentials WHERE installation_id = ? `).bind(installationId).first(); if (existingCredential) { throw new PublicError('INSTALLATION_ALREADY_ENROLLED', 409); } const credentialId = randomOpaqueSecret(12); const token = [INSTALLATION_TOKEN_PREFIX, credentialId, randomOpaqueSecret(32)].join('.'); const tokenHash = await sha256Hex(token); let activationResults; try { // D1 batch: код, credential и installation фиксируются одной транзакцией. // changes() ограничивает INSERT результатом непосредственно предыдущего UPDATE. activationResults = await env.DB.batch([ env.DB.prepare(` UPDATE enrollment_codes SET status = 'used', used_at = ?, installation_id = ? WHERE code_id = ? AND status = 'active' AND expires_at >= ? `).bind(now, installationId, codeRow.code_id, now), env.DB.prepare(` INSERT INTO installation_credentials ( credential_id, installation_id, token_hash, activation_code_id, status, manager_id, manager_name, stage, platform, created_at, updated_at, last_used_at, last_ip, last_user_agent ) SELECT ?, ?, ?, code_id, 'active', manager_id, manager_name, stage, platform, ?, ?, ?, ?, ? FROM enrollment_codes WHERE code_id = ? AND status = 'used' AND installation_id = ? AND used_at = ? AND changes() = 1 `).bind( credentialId, installationId, tokenHash, now, now, now, cleanText(request.headers.get('CF-Connecting-IP'), 60), cleanText(request.headers.get('User-Agent'), 200), codeRow.code_id, installationId, now ), env.DB.prepare(` INSERT INTO installations ( installation_id, manager_id, manager_name, stage, platform, script_version, state, online, last_seen_at, created_at, updated_at ) SELECT installation_id, manager_id, manager_name, stage, platform, ?, 'enrolled', 0, 0, ?, ? FROM installation_credentials WHERE credential_id = ? AND installation_id = ? ON CONFLICT(installation_id) DO UPDATE SET manager_id = excluded.manager_id, manager_name = excluded.manager_name, stage = excluded.stage, platform = excluded.platform, script_version = excluded.script_version, updated_at = excluded.updated_at `).bind(cleanText(payload?.script_version, 40), now, now, credentialId, installationId), ]); } catch (_) { throw new PublicError('ENROLLMENT_CONFLICT', 409); } if (!decisionChanges(activationResults[0])) { throw new PublicError('ACTIVATION_CODE_ALREADY_USED', 409); } if (!decisionChanges(activationResults[1]) || !decisionChanges(activationResults[2])) { throw new PublicError('ENROLLMENT_CONFLICT', 409); } return jsonResponse({ ok: true, installation_id: installationId, installation_token: token, manager_id: codeRow.manager_id, manager_name: codeRow.manager_name, stage: codeRow.stage, platform: codeRow.platform, token_shown_once: true, server_time: now, }, 201, true); } function normalizeStageKey(value) { return cleanText(value, 120) .trim() .toLocaleLowerCase('ru-RU') .replace(/\s+/g, ' '); } async function findRegisteredStage(env, stage) { const stageNorm = normalizeStageKey(stage); if (!stageNorm) return null; const alias = await env.DB.prepare(` SELECT s.manager_id, s.canonical_stage, s.stage_norm FROM manager_stage_aliases a INNER JOIN manager_stages s ON s.manager_id = a.manager_id WHERE a.alias_norm = ? AND s.active = 1 LIMIT 1 `).bind(stageNorm).first(); if (alias) return alias; return await env.DB.prepare(` SELECT manager_id, canonical_stage, stage_norm FROM manager_stages WHERE (stage_norm = ? OR canonical_stage = ?) AND active = 1 LIMIT 1 `).bind(stageNorm, cleanText(stage, 120).trim()).first(); } async function resolveRegisteredStage(env, stage, createIfMissing = true) { const displayStage = cleanText(stage, 120).trim(); const stageNorm = normalizeStageKey(displayStage); if (!stageNorm) throw new PublicError('STAGE_REQUIRED', 400); const existing = await findRegisteredStage(env, displayStage); if (existing || !createIfMissing) return existing; const now = Date.now(); const managerId = `mgr_${randomOpaqueSecret(12)}`; await env.DB.prepare(` INSERT OR IGNORE INTO manager_stages ( manager_id, canonical_stage, stage_norm, active, created_at, updated_at ) VALUES (?, ?, ?, 1, ?, ?) `).bind( managerId, displayStage, stageNorm, now, now ).run(); const registered = await findRegisteredStage(env, displayStage); if (!registered) throw new PublicError('STAGE_REGISTRY_ERROR', 500); await env.DB.prepare(` INSERT OR IGNORE INTO manager_stage_aliases ( alias_norm, manager_id, canonical_stage, created_at, updated_at ) VALUES (?, ?, ?, ?, ?) `).bind( stageNorm, registered.manager_id, registered.canonical_stage, now, now ).run(); return registered; } async function canonicalizeLegacyIngestPayload(env, payload) { const rawStage = cleanText( payload?.installation?.stage || payload?.stage, 120 ); if (!rawStage) return payload; const registered = await findRegisteredStage(env, rawStage); if (!registered) return payload; const canonical = { ...(payload || {}), manager_id: registered.manager_id, manager_name: registered.canonical_stage, stage: registered.canonical_stage, }; if (payload?.installation && typeof payload.installation === 'object') { canonical.installation = { ...payload.installation, manager_id: registered.manager_id, manager_name: registered.canonical_stage, stage: registered.canonical_stage, }; } return canonical; } async function handleAdminInstallationInvite(request, env, user) { if (!isSameOriginRequest(request)) { return jsonResponse({ ok: false, error: 'REQUEST_REJECTED' }, 403); } const payload = await readJsonBody(request); const requestedStage = cleanText(payload?.stage, 120).trim(); const platform = cleanText(payload?.platform, 40).toLowerCase(); const ttlMinutes = clampInt( payload?.ttl_minutes || ENROLLMENT_TTL_MS / 60000, 5, 24 * 60 ); if (!requestedStage) throw new PublicError('STAGE_REQUIRED', 400); if (!['max', 'telegram', 'whatsapp', 'gemspace'].includes(platform)) { throw new PublicError('PLATFORM_INVALID', 400); } const now = Date.now(); // Код для уже известного стейджа получает тот же постоянный manager_id. // Поэтому переустановка и подключение другого мессенджера больше не // создают нового менеджера в статистике. const registeredStage = await resolveRegisteredStage( env, requestedStage, true ); const managerId = registeredStage.manager_id; const stage = registeredStage.canonical_stage; const managerName = stage; const codeId = randomOpaqueSecret(12); const activationCode = [ ACTIVATION_CODE_PREFIX, codeId, randomOpaqueSecret(24), ].join('.'); await env.DB.prepare(` INSERT INTO enrollment_codes ( code_id, code_hash, status, manager_id, manager_name, stage, platform, created_by, created_login, created_at, expires_at ) VALUES (?, ?, 'active', ?, ?, ?, ?, ?, ?, ?, ?) `).bind( codeId, await sha256Hex(activationCode), managerId, managerName, stage, platform, Number(user.id), cleanText(user.login, 80), now, now + ttlMinutes * 60 * 1000 ).run(); await writeAudit( env, user, 'installation_invite_created', `${stage} (${platform})`, request ); return jsonResponse({ ok: true, activation_code: activationCode, stage, platform, expires_at: now + ttlMinutes * 60 * 1000, shown_once: true, server_time: now, }); } // Prepare the whole memory move before the first merge write. A source person // needs a new global person_id: both manager_id and person_id authenticate its // AES-GCM records. The returned statements run with credential reassignment. async function prepareMemoryStageMerge(env, sourceManagerId, targetManagerId) { if (sourceManagerId === targetManagerId) return []; const [peopleResult, linksResult, messagesResult] = await Promise.all([ env.DB.prepare('SELECT * FROM memory_people WHERE manager_id=? LIMIT ?').bind(sourceManagerId,MAX_MEMORY_MERGE_STATEMENTS + 1).all(), env.DB.prepare('SELECT * FROM memory_chat_links WHERE manager_id=? LIMIT ?').bind(sourceManagerId,MAX_MEMORY_MERGE_STATEMENTS + 1).all(), env.DB.prepare('SELECT * FROM memory_messages WHERE manager_id=? LIMIT ?').bind(sourceManagerId,MAX_MEMORY_MERGE_MESSAGES + 1).all() ]); const people=peopleResult.results||[], links=linksResult.results||[], messages=messagesResult.results||[]; // Six installation/telemetry updates join this same D1 transaction below. if (messages.length > MAX_MEMORY_MERGE_MESSAGES || 11 + 2*people.length + links.length + (messages.length ? 1 : 0) > MAX_MEMORY_MERGE_STATEMENTS) { throw new PublicError('MEMORY_MERGE_TOO_LARGE',409); } const [keyConflict, linkConflict] = await Promise.all([ env.DB.prepare(`SELECT 1 FROM memory_people s JOIN memory_people t ON t.manager_id=? AND t.memory_key=s.memory_key WHERE s.manager_id=? LIMIT 1`).bind(targetManagerId,sourceManagerId).first(), env.DB.prepare(`SELECT 1 FROM memory_chat_links s JOIN memory_chat_links t ON t.manager_id=? AND t.platform=s.platform AND t.client_key=s.client_key WHERE s.manager_id=? LIMIT 1`).bind(targetManagerId,sourceManagerId).first() ]); if (keyConflict || linkConflict) throw new PublicError('MEMORY_MERGE_CONFLICT',409); const mappedIds=new Map(people.map(person=>[person.person_id,crypto.randomUUID()])); const revisionChecks=people.map(() => 'OR NOT EXISTS(SELECT 1 FROM memory_people WHERE manager_id=? AND person_id=? AND revision=?)').join(' '); const messageSnapshot=JSON.stringify(messages.map(message=>({ person_id:message.person_id, platform:message.platform, client_key:message.client_key, message_id:message.message_id, direction:message.direction, time:message.time, type:message.type, ciphertext:message.ciphertext, iv:message.iv, key_version:message.key_version }))); if (new TextEncoder().encode(messageSnapshot).length > MAX_MEMORY_MERGE_JSON_BYTES) { throw new PublicError('MEMORY_MERGE_TOO_LARGE',409); } const guard=env.DB.prepare(`INSERT INTO memory_people(person_id,manager_id,memory_key,created_at,updated_at) SELECT NULL,NULL,NULL,0,0 WHERE (SELECT COUNT(*) FROM memory_people WHERE manager_id=?)<>? OR (SELECT COUNT(*) FROM memory_chat_links WHERE manager_id=?)<>? OR (SELECT COUNT(*) FROM memory_messages WHERE manager_id=?)<>? OR EXISTS(SELECT 1 FROM json_each(?) snapshot LEFT JOIN memory_messages current ON current.manager_id=? AND current.person_id=json_extract(snapshot.value,'$.person_id') AND current.platform=json_extract(snapshot.value,'$.platform') AND current.client_key=json_extract(snapshot.value,'$.client_key') AND current.message_id=json_extract(snapshot.value,'$.message_id') WHERE current.ciphertext IS NULL OR current.ciphertext<>json_extract(snapshot.value,'$.ciphertext') OR current.iv<>json_extract(snapshot.value,'$.iv') OR current.key_version<>json_extract(snapshot.value,'$.key_version') OR current.direction<>json_extract(snapshot.value,'$.direction') OR current.time<>json_extract(snapshot.value,'$.time') OR current.type<>json_extract(snapshot.value,'$.type')) ${revisionChecks}`) .bind(sourceManagerId,people.length,sourceManagerId,links.length,sourceManagerId,messages.length, messageSnapshot,sourceManagerId, ...people.flatMap(person=>[sourceManagerId,person.person_id,person.revision])); const statements=[guard]; for (const person of people) { const personId=mappedIds.get(person.person_id); let dossier=['','',0]; if (person.dossier_ciphertext) { const value=await memoryDecrypt(env,sourceManagerId,person.person_id,'dossier','profile',person.dossier_ciphertext,person.dossier_iv,person.dossier_key_version); dossier=await memoryEncrypt(env,targetManagerId,personId,'dossier','profile',value); } statements.push(env.DB.prepare(`INSERT INTO memory_people(person_id,manager_id,memory_key,dossier_ciphertext,dossier_iv,dossier_key_version,revision,incomplete,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?)`).bind(personId,targetManagerId,person.memory_key,...dossier,person.revision,person.incomplete,person.created_at,person.updated_at)); } for (const link of links) { const personId=mappedIds.get(link.person_id); if (!personId) throw new PublicError('MEMORY_MERGE_INCONSISTENT',409); statements.push(env.DB.prepare(`INSERT INTO memory_chat_links(manager_id,platform,client_key,person_id,linked_at,source,import_complete) VALUES(?,?,?,?,?,?,?)`).bind(targetManagerId,link.platform,link.client_key,personId,link.linked_at,link.source,link.import_complete)); } const encryptedMessages=[]; for (const message of messages) { const personId=mappedIds.get(message.person_id); if (!personId) throw new PublicError('MEMORY_MERGE_INCONSISTENT',409); const recordId=memoryRecordId(message.platform,message.client_key,message.message_id); const value=await memoryDecrypt(env,sourceManagerId,message.person_id,'message',recordId,message.ciphertext,message.iv,message.key_version); if (value.direction!==message.direction || value.time!==message.time || value.type!==message.type) throw new PublicError('MEMORY_DECRYPTION_FAILED',503); const encrypted=await memoryEncrypt(env,targetManagerId,personId,'message',recordId,value); encryptedMessages.push({manager_id:targetManagerId,person_id:personId,platform:message.platform, client_key:message.client_key,message_id:message.message_id,direction:message.direction, time:message.time,type:message.type,ciphertext:encrypted[0],iv:encrypted[1], key_version:encrypted[2],created_at:message.created_at}); } if (encryptedMessages.length) { const json=JSON.stringify(encryptedMessages); if (new TextEncoder().encode(json).length > MAX_MEMORY_MERGE_JSON_BYTES) { throw new PublicError('MEMORY_MERGE_TOO_LARGE',409); } statements.push(env.DB.prepare(`INSERT INTO memory_messages(manager_id,person_id,platform,client_key,message_id,direction,time,type,ciphertext,iv,key_version,created_at) SELECT json_extract(value,'$.manager_id'),json_extract(value,'$.person_id'), json_extract(value,'$.platform'),json_extract(value,'$.client_key'), json_extract(value,'$.message_id'),json_extract(value,'$.direction'), json_extract(value,'$.time'),json_extract(value,'$.type'), json_extract(value,'$.ciphertext'),json_extract(value,'$.iv'), json_extract(value,'$.key_version'),json_extract(value,'$.created_at') FROM json_each(?)`).bind(json)); } for (const person of people) statements.push(env.DB.prepare('UPDATE memory_link_events SET manager_id=?,person_id=? WHERE manager_id=? AND person_id=?') .bind(targetManagerId,mappedIds.get(person.person_id),sourceManagerId,person.person_id)); // Historical events for already-deleted people have no parent to remap. statements.push(env.DB.prepare('UPDATE memory_link_events SET manager_id=? WHERE manager_id=?').bind(targetManagerId,sourceManagerId)); statements.push(env.DB.prepare('DELETE FROM memory_messages WHERE manager_id=?').bind(sourceManagerId)); statements.push(env.DB.prepare('DELETE FROM memory_chat_links WHERE manager_id=?').bind(sourceManagerId)); statements.push(env.DB.prepare('DELETE FROM memory_people WHERE manager_id=?').bind(sourceManagerId)); return statements; } function selectKeptStageMergeInstallations(rows, targetInstallationIds) { const preferred = []; const preferredIds = new Set(); const preferredPlatforms = new Set(); for (const row of rows) { const platform = String(row.platform || 'unknown').toLowerCase(); if (Number(row.personal_token_active) !== 1 || !targetInstallationIds.has(row.installation_id) || preferredPlatforms.has(platform)) continue; preferred.push(row); preferredIds.add(row.installation_id); preferredPlatforms.add(platform); } const ordered = [...preferred, ...rows.filter(row => !preferredIds.has(row.installation_id))]; const keptPlatforms = new Set(); const kept = []; const superseded = []; for (const row of ordered) { const platform = String(row.platform || 'unknown').toLowerCase(); if (!keptPlatforms.has(platform)) { keptPlatforms.add(platform); kept.push(row.installation_id); } else { superseded.push(row.installation_id); } } return {kept, superseded}; } async function handleAdminStageMerge(request, env, user) { if (!isSameOriginRequest(request)) { return jsonResponse({ ok: false, error: 'REQUEST_REJECTED' }, 403); } const payload = await readJsonBody(request); const sourceInput = cleanText(payload?.source_stage, 120).trim(); const targetInput = cleanText(payload?.target_stage, 120).trim(); if (!sourceInput || !targetInput) { throw new PublicError('STAGES_REQUIRED', 400); } if (normalizeStageKey(sourceInput) === normalizeStageKey(targetInput)) { throw new PublicError('STAGES_MUST_DIFFER', 400); } const now = Date.now(); const source = await resolveRegisteredStage(env, sourceInput, true); const target = await resolveRegisteredStage(env, targetInput, true); const sourceStage = source.canonical_stage; const targetStage = target.canonical_stage; const targetManagerId = target.manager_id; const targetRows = await env.DB.prepare(`SELECT installation_id FROM installations WHERE manager_id=?`).bind(targetManagerId).all(); const targetInstallationIds = new Set((targetRows.results || []).map(row => row.installation_id)); const memoryMove = await prepareMemoryStageMerge(env,source.manager_id,targetManagerId); // Перед сменой manager_id перепаковываем копии с новым AES-GCM AAD. // Исходные строки остаются до успешного завершения объединения. await copyStageBackupsForStageMerge(env, source.manager_id, targetManagerId); // Если псевдоним уже направлен в нужный стейдж, повторный клик безопасен. if ( source.manager_id !== targetManagerId || sourceStage !== targetStage ) { const identityUpdates = [ env.DB.prepare(` UPDATE installations SET manager_id = ?, manager_name = ?, stage = ?, updated_at = ? WHERE manager_id = ? OR stage = ? `).bind( targetManagerId, targetStage, targetStage, now, source.manager_id, sourceStage ), env.DB.prepare(` UPDATE installation_credentials SET manager_id = ?, manager_name = ?, stage = ?, updated_at = ? WHERE manager_id = ? OR stage = ? `).bind( targetManagerId, targetStage, targetStage, now, source.manager_id, sourceStage ), env.DB.prepare(` UPDATE enrollment_codes SET manager_id = ?, manager_name = ?, stage = ? WHERE manager_id = ? OR stage = ? `).bind( targetManagerId, targetStage, targetStage, source.manager_id, sourceStage ), env.DB.prepare(` UPDATE event_logs SET manager_id = ?, stage = ? WHERE manager_id = ? OR stage = ? `).bind( targetManagerId, targetStage, source.manager_id, sourceStage ), env.DB.prepare(` UPDATE decision_requests SET manager_id = ?, manager_name = ?, stage = ?, updated_at = ? WHERE manager_id = ? OR stage = ? `).bind( targetManagerId, targetStage, targetStage, now, source.manager_id, sourceStage ), env.DB.prepare(` UPDATE ai_usage SET manager_id = ?, stage = ? WHERE manager_id = ? OR stage = ? `).bind( targetManagerId, targetStage, source.manager_id, sourceStage ), ]; await env.DB.batch([...memoryMove,...identityUpdates]); } // Доступ тимлидера также переносится на правильное написание. if (sourceStage !== targetStage) { await env.DB.prepare(` INSERT OR IGNORE INTO user_stages (user_id, stage) SELECT user_id, ? FROM user_stages WHERE stage = ? `).bind(targetStage, sourceStage).run(); await env.DB.prepare( 'DELETE FROM user_stages WHERE stage = ?' ).bind(sourceStage).run(); } await env.DB.prepare(` INSERT INTO manager_stage_aliases ( alias_norm, manager_id, canonical_stage, created_at, updated_at ) VALUES (?, ?, ?, ?, ?) ON CONFLICT(alias_norm) DO UPDATE SET manager_id = excluded.manager_id, canonical_stage = excluded.canonical_stage, updated_at = excluded.updated_at `).bind( normalizeStageKey(sourceInput), targetManagerId, targetStage, now, now ).run(); await env.DB.prepare(` INSERT INTO manager_stage_aliases ( alias_norm, manager_id, canonical_stage, created_at, updated_at ) VALUES (?, ?, ?, ?, ?) ON CONFLICT(alias_norm) DO UPDATE SET manager_id = excluded.manager_id, canonical_stage = excluded.canonical_stage, updated_at = excluded.updated_at `).bind( normalizeStageKey(sourceStage), targetManagerId, targetStage, now, now ).run(); if (source.manager_id !== targetManagerId) { await env.DB.prepare(` UPDATE manager_stages SET active = 0, updated_at = ? WHERE manager_id = ? `).bind(now, source.manager_id).run(); } // После объединения на каждой платформе должен остаться один исполнитель. // Сохраняем активную установку правильного стейджа на каждой платформе. // Если её нет, используем активную установку с самым свежим сигналом. const installations = await env.DB.prepare(` SELECT i.installation_id, i.platform, i.last_seen_at, i.updated_at, CASE WHEN c.status = 'active' THEN 1 ELSE 0 END AS personal_token_active FROM installations i LEFT JOIN installation_credentials c ON c.installation_id = i.installation_id WHERE i.manager_id = ? AND i.stage = ? ORDER BY personal_token_active DESC, i.last_seen_at DESC, i.updated_at DESC, i.installation_id DESC `).bind(targetManagerId, targetStage).all(); const {kept, superseded} = selectKeptStageMergeInstallations( installations.results || [], targetInstallationIds); for (const installationId of superseded) { await env.DB.prepare(` UPDATE installation_credentials SET status = 'revoked', revoked_at = ?, updated_at = ? WHERE installation_id = ? AND status <> 'revoked' `).bind(now, now, installationId).run(); await env.DB.prepare(` UPDATE installations SET online = 0, state = 'superseded', updated_at = ? WHERE installation_id = ? `).bind(now, installationId).run(); await env.DB.prepare( 'DELETE FROM client_states WHERE installation_id = ?' ).bind(installationId).run(); await env.DB.prepare( 'DELETE FROM installation_stops WHERE installation_id = ?' ).bind(installationId).run().catch(() => {}); await env.DB.prepare(` UPDATE decision_requests SET status = 'cancelled', execution_status = 'installation_superseded', execution_error = 'INSTALLATION_SUPERSEDED', lease_owner = '', lease_token_hash = '', lease_expires_at = 0, reason = '', manager_task = '', recommendation = '', context_json = '[]', resolution_text = '', sensitive_ciphertext = '', sensitive_iv = '', sensitive_key_version = 0, sensitive_purged_at = ?, updated_at = ? WHERE installation_id = ? AND status IN ('pending', 'resolved') `).bind(now, now, installationId).run(); } await writeAudit( env, user, 'stages_merged', `${sourceInput} -> ${targetStage}; revoked=${superseded.length}`, request ); if (source.manager_id !== targetManagerId) { await env.DB.prepare(` DELETE FROM max_stage_backups WHERE manager_id = ? `).bind(source.manager_id).run(); } return jsonResponse({ ok: true, source_stage: sourceInput, canonical_stage: targetStage, manager_id: targetManagerId, kept_installations: kept, revoked_installations: superseded, server_time: now, }); } async function handleAdminInstallationRotate(request, env, user) { if (!isSameOriginRequest(request)) { return jsonResponse({ ok: false, error: 'REQUEST_REJECTED' }, 403); } const payload = await readJsonBody(request); const installationId = cleanText(payload?.installation_id, 160); if (!installationId) { throw new PublicError('INSTALLATION_ID_REQUIRED', 400); } const credential = await env.DB.prepare(` SELECT * FROM installation_credentials WHERE installation_id = ? `).bind(installationId).first(); if (!credential) throw new PublicError('INSTALLATION_NOT_ENROLLED', 404); const token = [ INSTALLATION_TOKEN_PREFIX, credential.credential_id, randomOpaqueSecret(32), ].join('.'); const now = Date.now(); await env.DB.prepare(` UPDATE installation_credentials SET token_hash = ?, status = 'active', rotated_at = ?, revoked_at = 0, updated_at = ? WHERE credential_id = ? `).bind( await sha256Hex(token), now, now, credential.credential_id ).run(); await writeAudit( env, user, 'installation_token_rotated', installationId, request ); return jsonResponse({ ok: true, installation_id: installationId, installation_token: token, shown_once: true, server_time: now, }); } async function handleAdminInstallationRevoke(request, env, user) { if (!isSameOriginRequest(request)) { return jsonResponse({ ok: false, error: 'REQUEST_REJECTED' }, 403); } const payload = await readJsonBody(request); const installationId = cleanText(payload?.installation_id, 160); if (!installationId) { throw new PublicError('INSTALLATION_ID_REQUIRED', 400); } const now = Date.now(); const result = await env.DB.prepare(` UPDATE installation_credentials SET status = 'revoked', revoked_at = ?, updated_at = ? WHERE installation_id = ? AND status <> 'revoked' `).bind(now, now, installationId).run(); if (!decisionChanges(result)) { throw new PublicError('INSTALLATION_NOT_ENROLLED', 404); } await env.DB.prepare(` UPDATE installations SET online = 0, state = 'revoked', updated_at = ? WHERE installation_id = ? `).bind(now, installationId).run(); await writeAudit( env, user, 'installation_revoked', installationId, request ); return jsonResponse({ ok: true, installation_id: installationId, status: 'revoked', server_time: now, }); } async function handleAdminInstallationSecurity(env) { const now = Date.now(); const [credentials, codes, stages] = await Promise.all([ env.DB.prepare(` SELECT credential_id, installation_id, status, manager_id, manager_name, stage, platform, created_at, updated_at, last_used_at, rotated_at, revoked_at, last_ip FROM installation_credentials ORDER BY updated_at DESC LIMIT 1000 `).all(), env.DB.prepare(` SELECT code_id, status, manager_id, manager_name, stage, platform, created_login, created_at, expires_at, used_at, installation_id FROM enrollment_codes WHERE created_at >= ? ORDER BY created_at DESC LIMIT 300 `).bind(now - 30 * 24 * 60 * 60 * 1000).all(), env.DB.prepare(` SELECT stage FROM ( SELECT canonical_stage AS stage FROM manager_stages WHERE active = 1 UNION SELECT TRIM(stage) AS stage FROM installations WHERE TRIM(COALESCE(stage, '')) <> '' ) ORDER BY stage COLLATE NOCASE `).all(), ]); return jsonResponse({ ok: true, security: { data_encryption_key_configured: isDataEncryptionKeyValid(env), api_rate_limiter_configured: Boolean(env.API_RATE_LIMITER), ai_gateway_configured: Boolean(env.ANTHROPIC_API_KEY), ai_rate_limiter_configured: Boolean( env.AI_RATE_LIMITER || env.API_RATE_LIMITER ), ai_installation_daily_limit_usd: aiInstallationDailyLimitUsd(env), ai_global_daily_limit_usd: aiGlobalDailyLimitUsd(env), enrollment_rate_limiter_configured: Boolean(env.ENROLL_RATE_LIMITER), login_rate_limiter_configured: Boolean(env.LOGIN_RATE_LIMITER), legacy_ingest_enabled: Boolean(env.INGEST_TOKEN), decisions_require_personal_tokens: true, }, credentials: credentials.results || [], enrollment_codes: codes.results || [], stages: (stages.results || []) .map((row) => cleanText(row.stage, 120)) .filter(Boolean), server_time: now, }); } async function handleHeartbeat(env, payload) { const now = Date.now(); const data = normalizeInstallation(payload, now); await upsertInstallation(env, data, now); const supersededInstallations = payload?.__auth_mode === 'installation' ? await supersedeOtherInstallations(env, data, now) : []; const stop = await readGlobalStop(env); const versions = await readLatestVersions(env); const latestVersion = versions[data.platform] || ''; const personalStop = await readInstallationStop(env, data.installation_id); const restart = await readRestartReminder(env); return jsonResponse( { ok: true, accepted: 'heartbeat', installation_id: data.installation_id, ...(payload?.__auth_mode === 'installation' ? { identity_confirmed: true, manager_id: data.manager_id, manager_name: data.manager_name, stage: data.stage, platform: data.platform } : { identity_confirmed: false }), server_time: now, // Актуальная версия для этой платформы (пусто = не задана) latest_version: latestVersion, // Ежедневное напоминание о перезапуске браузера restart_reminder: restart, // Стоп-кран администратора: скрипт обязан прекратить работу global_stop: stop.stopped, // Индивидуальная остановка именно этой установки installation_stop: personalStop, superseded_installations: supersededInstallations.length, global_stop_at: stop.at, global_stop_by: stop.by, }, 200, true ); } // Повторная установка безопасна: старый персональный токен отключается // только после первого успешного heartbeat новой установки. Если новая // активация не запустилась, прежний скрипт продолжает работать. async function supersedeOtherInstallations(env, data, now) { if (!data.manager_id || !data.platform || !data.installation_id) return []; const oldRows = await env.DB.prepare(` SELECT older.installation_id FROM installation_credentials older INNER JOIN installation_credentials current ON current.installation_id = ? AND current.status = 'active' WHERE older.manager_id = ? AND older.platform = ? AND older.installation_id <> ? AND older.status = 'active' AND ( older.created_at < current.created_at OR ( older.created_at = current.created_at AND older.credential_id < current.credential_id ) ) ORDER BY older.created_at DESC LIMIT 100 `).bind( data.installation_id, data.manager_id, data.platform, data.installation_id ).all(); const ids = (oldRows.results || []) .map((row) => cleanText(row.installation_id, 160)) .filter(Boolean); if (!ids.length) return []; const placeholders = ids.map(() => '?').join(', '); await env.DB.batch([ env.DB.prepare(` UPDATE installation_credentials SET status = 'revoked', revoked_at = ?, updated_at = ? WHERE status = 'active' AND installation_id IN (${placeholders}) `).bind(now, now, ...ids), env.DB.prepare(` UPDATE installations SET online = 0, state = 'superseded', updated_at = ? WHERE installation_id IN (${placeholders}) `).bind(now, ...ids), env.DB.prepare(` UPDATE decision_requests SET status = 'cancelled', execution_status = 'installation_superseded', execution_error = 'INSTALLATION_SUPERSEDED', lease_owner = '', lease_token_hash = '', lease_expires_at = 0, reason = '', manager_task = '', recommendation = '', context_json = '[]', resolution_text = '', sensitive_ciphertext = '', sensitive_iv = '', sensitive_key_version = 0, sensitive_purged_at = ?, updated_at = ? WHERE installation_id IN (${placeholders}) AND status IN ('pending', 'resolved') `).bind(now, now, ...ids), ]); return ids; } async function handleClients(env, payload) { const now = Date.now(); const meta = normalizeInstallationMeta(payload, now); const clients = normalizeClients(payload?.clients, meta, now); // Старые версии могли прислать пустой либо частичный массив и тем самым // удалить из CRM всех клиентов установки. Полная замена теперь включается // только явным подтверждённым снимком нового формата. const snapshotPolicy = clientSnapshotPolicy(payload, clients); const replace = snapshotPolicy.replace; await ensureInstallation(env, meta, now); if (replace) { await replaceClientSnapshot( env, meta.installation_id, clients, snapshotPolicy.allow_empty_replace ); } else { await upsertClients(env, clients); } if (replace) { const activeClients = clients.reduce( (count, client) => count + (client.enabled ? 1 : 0), 0 ); // Синхронизируем heartbeat-счётчики с уже принятым снимком. Теперь // карточка установки и подробная таблица не могут показывать разные // количества после успешного POST /clients. await env.DB.prepare(` UPDATE installations SET clients_total = ?, clients_active = ?, updated_at = ? WHERE installation_id = ? `).bind( clients.length, activeClients, now, meta.installation_id ).run(); } return jsonResponse( { ok: true, accepted: 'clients', count: clients.length, replaced: replace, replace_requested: snapshotPolicy.replace_requested, snapshot_complete: snapshotPolicy.snapshot_complete, snapshot_schema_version: snapshotPolicy.schema_version, replace_skipped_reason: snapshotPolicy.skipped_reason, installation_id: meta.installation_id, server_time: now, }, 200, true ); } async function handleEvents(env, payload) { const now = Date.now(); const meta = normalizeInstallationMeta(payload, now); const rawEvents = Array.isArray(payload?.events) ? payload.events : [payload?.event || payload]; const events = normalizeEvents(rawEvents, meta, now); await ensureInstallation(env, meta, now); await insertEvents(env, events); return jsonResponse( { ok: true, accepted: 'events', count: events.length, installation_id: meta.installation_id, server_time: now, }, 200, true ); } async function handleSnapshot(env, payload) { const now = Date.now(); const installationPayload = payload?.installation || payload; const installation = normalizeInstallation(installationPayload, now); const meta = { installation_id: installation.installation_id, manager_id: installation.manager_id, stage: installation.stage, platform: installation.platform, script_version: installation.script_version, }; const clients = normalizeClients(payload?.clients || [], meta, now); const events = normalizeEvents(payload?.events || [], meta, now); const snapshotPolicy = clientSnapshotPolicy(payload, clients); const replaceClients = snapshotPolicy.replace; await upsertInstallation(env, installation, now); if (replaceClients) { await replaceClientSnapshot( env, meta.installation_id, clients, snapshotPolicy.allow_empty_replace ); } else { await upsertClients(env, clients); } await insertEvents(env, events); return jsonResponse( { ok: true, accepted: 'snapshot', clients: clients.length, clients_replaced: replaceClients, replace_requested: snapshotPolicy.replace_requested, snapshot_complete: snapshotPolicy.snapshot_complete, snapshot_schema_version: snapshotPolicy.schema_version, replace_skipped_reason: snapshotPolicy.skipped_reason, events: events.length, installation_id: installation.installation_id, server_time: now, }, 200, true ); } function clientSnapshotPolicy(payload, clients) { const schemaVersion = clampInt( payload?.client_snapshot_schema_version || payload?.snapshot_schema_version || payload?.schema_version || 0, 0, 100 ); const replaceRequested = payload?.replace === true || payload?.replace_clients === true; const snapshotComplete = payload?.snapshot_complete === true; const allowEmpty = payload?.allow_empty_snapshot === true; let skippedReason = ''; if (!replaceRequested) skippedReason = 'replace_not_requested'; else if (!snapshotComplete) skippedReason = 'snapshot_not_marked_complete'; else if (schemaVersion < CLIENT_SNAPSHOT_SCHEMA_VERSION) { skippedReason = 'snapshot_schema_too_old'; } else if (!clients.length && !allowEmpty) { skippedReason = 'empty_snapshot_requires_confirmation'; } const replace = !skippedReason; return { replace, replace_requested: replaceRequested, snapshot_complete: snapshotComplete, schema_version: schemaVersion, allow_empty_replace: replace && !clients.length && allowEmpty, skipped_reason: skippedReason, }; } function normalizeInstallation(payload, now) { const meta = normalizeInstallationMeta(payload, now); return { ...meta, manager_name: cleanText(payload?.manager_name, 120), session_id: cleanText(payload?.session_id, 160), state: cleanText(payload?.state || 'unknown', 40), paused: boolInt(payload?.paused), busy: boolInt(payload?.busy), online: payload?.online === false ? 0 : 1, // Старые скрипты без page_visible считаем видимыми. Состояние «в фоне» // включается только когда клиент явно передал false или 0. page_visible: payload?.page_visible === false || payload?.page_visible === 0 ? 0 : 1, clients_total: clampInt(payload?.clients_total, 0, 100000), clients_active: clampInt(payload?.clients_active, 0, 100000), started_at: safeTimestamp(payload?.started_at), last_reason: cleanText(payload?.reason, 100), created_at: now, updated_at: now, }; } function normalizeInstallationMeta(payload, now) { const installationId = cleanText( payload?.installation_id || payload?.installationId, 160 ); if (!installationId) { throw new PublicError('INSTALLATION_ID_REQUIRED', 400); } return { installation_id: installationId, manager_id: cleanText(payload?.manager_id, 120), stage: cleanText(payload?.stage, 120), platform: cleanText(payload?.platform || 'unknown', 40).toLowerCase(), script_version: cleanText(payload?.script_version, 40), sent_at: safeTimestamp(payload?.sent_at) || now, }; } function normalizeClients(rawClients, meta, now) { if (!Array.isArray(rawClients)) { throw new PublicError('CLIENTS_ARRAY_REQUIRED', 400); } if (rawClients.length > MAX_CLIENTS_PER_REQUEST) { throw new PublicError('TOO_MANY_CLIENTS', 413); } return rawClients.map((raw) => { const clientKey = cleanText( raw?.client_key || raw?.clientKey, 160 ); if (!clientKey) { throw new PublicError('CLIENT_KEY_REQUIRED', 400); } const enabled = raw?.enabled === false ? 0 : 1; const onCall = boolInt(raw?.on_call); const awaitingHuman = boolInt(raw?.awaiting_human); const lastInAt = safeObservedTimestamp(raw?.last_in_at, now); const lastOutAt = safeObservedTimestamp(raw?.last_out_at, now); const lastSender = normalizeLastSender(raw?.last_sender); const status = deriveClientStatus({ requested: raw?.status, enabled, onCall, awaitingHuman, lastInAt, lastOutAt, lastSender, }); return { installation_id: meta.installation_id, client_key: clientKey, client_label: cleanText(raw?.client_label, 120), platform: cleanText(meta.platform || raw?.platform, 40).toLowerCase(), enabled, status, on_call: onCall, awaiting_human: awaitingHuman, last_in_at: lastInAt, last_out_at: lastOutAt, last_sender: lastSender, unanswered_since: status === 'waiting_ai' ? safeTimestamp(raw?.unanswered_since) || lastInAt : 0, next_action_at: safeTimestamp(raw?.next_action_at), last_success_at: safeTimestamp(raw?.last_success_at), last_error_code: cleanCode(raw?.last_error_code, 100), last_trace_id: cleanText(raw?.last_trace_id, 160), updated_at: now, }; }); } function normalizeEvents(rawEvents, meta, now) { if (!Array.isArray(rawEvents)) { throw new PublicError('EVENTS_ARRAY_REQUIRED', 400); } if (rawEvents.length > MAX_EVENTS_PER_REQUEST) { throw new PublicError('TOO_MANY_EVENTS', 413); } return rawEvents .filter((item) => item && typeof item === 'object') .map((raw) => { const levelCandidate = cleanText(raw?.level || 'info', 20).toLowerCase(); const level = LEVEL_VALUES.has(levelCandidate) ? levelCandidate : 'info'; return { event_id: cleanText(raw?.event_id, 160) || crypto.randomUUID(), trace_id: cleanText(raw?.trace_id, 160), installation_id: meta.installation_id, client_key: cleanText(raw?.client_key, 160), manager_id: cleanText(meta.manager_id || raw?.manager_id, 120), stage: cleanText(meta.stage || raw?.stage, 120), platform: cleanText(meta.platform || raw?.platform, 40).toLowerCase(), script_version: cleanText( raw?.script_version || meta.script_version, 40 ), event_type: cleanCode(raw?.event_type || 'diagnostic', 80), step: cleanCode(raw?.step, 100), level, code: cleanCode(raw?.code, 100), message: redactSensitive(raw?.message, 700), details_json: safeDetailsJson(raw?.details), created_at: safeObservedTimestamp(raw?.created_at, now) || now, received_at: now, }; }); } function deriveClientStatus(input) { if (!input.enabled) return 'paused'; if (input.onCall) return 'call'; if (input.awaitingHuman) return 'human'; if ( input.lastSender === 'client' || (input.lastInAt > 0 && input.lastInAt > input.lastOutAt) ) { return 'waiting_ai'; } if ( input.lastSender === 'assistant' || input.lastSender === 'manager' || (input.lastOutAt > 0 && input.lastOutAt >= input.lastInAt) ) { return 'waiting_client'; } const requested = cleanText(input.requested, 40).toLowerCase(); return STATUS_VALUES.has(requested) ? requested : 'active'; } async function upsertInstallation(env, data, now) { await env.DB.prepare(` INSERT INTO installations ( installation_id, manager_id, manager_name, stage, platform, script_version, session_id, state, paused, busy, online, page_visible, page_hidden_since, clients_total, clients_active, started_at, last_seen_at, last_reason, created_at, updated_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(installation_id) DO UPDATE SET manager_id = excluded.manager_id, manager_name = excluded.manager_name, stage = excluded.stage, platform = excluded.platform, script_version = excluded.script_version, session_id = excluded.session_id, state = excluded.state, paused = excluded.paused, busy = excluded.busy, online = excluded.online, page_visible = excluded.page_visible, page_hidden_since = CASE WHEN excluded.page_visible = 1 THEN 0 WHEN installations.page_visible = 1 OR installations.page_hidden_since = 0 THEN excluded.last_seen_at ELSE installations.page_hidden_since END, clients_total = excluded.clients_total, clients_active = excluded.clients_active, started_at = excluded.started_at, last_seen_at = excluded.last_seen_at, last_reason = excluded.last_reason, updated_at = excluded.updated_at `) .bind( data.installation_id, data.manager_id, data.manager_name, data.stage, data.platform, data.script_version, data.session_id, data.state, data.paused, data.busy, data.online, data.page_visible, data.page_visible ? 0 : now, data.clients_total, data.clients_active, data.started_at, now, data.last_reason, data.created_at || now, now ) .run(); } async function ensureInstallation(env, meta, now) { await env.DB.prepare(` INSERT OR IGNORE INTO installations ( installation_id, manager_id, stage, platform, script_version, last_seen_at, created_at, updated_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) `) .bind( meta.installation_id, meta.manager_id, meta.stage, meta.platform, meta.script_version, now, now, now ) .run(); } async function upsertClients(env, clients) { if (!clients.length) return; const statements = clients.map((client) => env.DB.prepare(` INSERT INTO client_states ( installation_id, client_key, client_label, platform, enabled, status, on_call, awaiting_human, last_in_at, last_out_at, last_sender, unanswered_since, next_action_at, last_success_at, last_error_code, last_trace_id, updated_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(installation_id, client_key) DO UPDATE SET client_label = excluded.client_label, platform = excluded.platform, enabled = excluded.enabled, status = excluded.status, on_call = excluded.on_call, awaiting_human = excluded.awaiting_human, last_in_at = excluded.last_in_at, last_out_at = excluded.last_out_at, last_sender = excluded.last_sender, unanswered_since = excluded.unanswered_since, next_action_at = excluded.next_action_at, last_success_at = excluded.last_success_at, last_error_code = excluded.last_error_code, last_trace_id = excluded.last_trace_id, updated_at = excluded.updated_at `).bind( client.installation_id, client.client_key, client.client_label, client.platform, client.enabled, client.status, client.on_call, client.awaiting_human, client.last_in_at, client.last_out_at, client.last_sender, client.unanswered_since, client.next_action_at, client.last_success_at, client.last_error_code, client.last_trace_id, client.updated_at ) ); await runBatches(env.DB, statements, 50); } async function replaceClientSnapshot( env, installationId, clients, allowEmptyDelete = false ) { if (!clients.length) { if (!allowEmptyDelete) { throw new PublicError('EMPTY_SNAPSHOT_NOT_CONFIRMED', 409); } await env.DB.prepare(` DELETE FROM client_states WHERE installation_id = ? `).bind(installationId).run(); return; } // Сначала сохраняем новый снимок. Если запись оборвётся, старые клиенты // останутся на месте, а не исчезнут из панели полностью. const snapshotAt = Math.min( ...clients.map((client) => Number(client.updated_at || Date.now())) ); await upsertClients(env, clients); await env.DB.prepare(` DELETE FROM client_states WHERE installation_id = ? AND updated_at < ? `).bind(installationId, snapshotAt).run(); } async function insertEvents(env, events) { if (!events.length) return; const statements = events.map((event) => env.DB.prepare(` INSERT OR IGNORE INTO event_logs ( event_id, trace_id, installation_id, client_key, manager_id, stage, platform, script_version, event_type, step, level, code, message, details_json, created_at, received_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `).bind( event.event_id, event.trace_id, event.installation_id, event.client_key, event.manager_id, event.stage, event.platform, event.script_version, event.event_type, event.step, event.level, event.code, event.message, event.details_json, event.created_at, event.received_at ) ); await runBatches(env.DB, statements, 50); } async function runBatches(database, statements, size) { for (let start = 0; start < statements.length; start += size) { await database.batch(statements.slice(start, start + size)); } } async function handleAdminDecisions(env, url, user) { const now = Date.now(); await expireDecisionRequests(env, now); const limit = clampInt(url.searchParams.get('limit') || 200, 1, 500); const historyHours = clampInt( url.searchParams.get('hours') || 72, 1, 24 * 14 ); const historyAfter = now - historyHours * 60 * 60 * 1000; const filter = stageFilter(user, 'd.stage'); const [rows, summary] = await Promise.all([ env.DB.prepare(` SELECT d.* FROM decision_requests d WHERE ( d.status IN ('pending', 'resolved') OR d.updated_at >= ? )${filter.sql} AND (? = 1 OR d.source <> 'site_test') ORDER BY CASE d.status WHEN 'pending' THEN 0 WHEN 'resolved' THEN 1 WHEN 'failed' THEN 2 WHEN 'executed' THEN 3 WHEN 'expired' THEN 4 ELSE 5 END, CASE WHEN d.status = 'pending' THEN d.created_at ELSE d.updated_at END DESC LIMIT ? `).bind( historyAfter, ...filter.params, canSeeErrors(user) ? 1 : 0, limit ).all(), env.DB.prepare(` SELECT SUM(CASE WHEN d.status = 'pending' THEN 1 ELSE 0 END) AS pending_total, SUM(CASE WHEN d.status = 'pending' AND d.decision_type = 'general' THEN 1 ELSE 0 END) AS pending_general, SUM(CASE WHEN d.status = 'pending' AND d.decision_type = 'call' THEN 1 ELSE 0 END) AS pending_call, SUM(CASE WHEN d.status = 'resolved' THEN 1 ELSE 0 END) AS awaiting_execution, SUM(CASE WHEN d.status = 'failed' AND d.updated_at >= ? THEN 1 ELSE 0 END) AS failed_recent FROM decision_requests d WHERE 1 = 1${filter.sql} AND (? = 1 OR d.source <> 'site_test') `).bind( historyAfter, ...filter.params, canSeeErrors(user) ? 1 : 0 ).first(), ]); const visibleDecisions = await Promise.all( (rows.results || []).map((row) => decisionRowForUser(env, row, user, true) ) ); return jsonResponse({ ok: true, server_time: now, summary: { pending_total: numberOrZero(summary?.pending_total), pending_general: numberOrZero(summary?.pending_general), pending_call: numberOrZero(summary?.pending_call), awaiting_execution: numberOrZero(summary?.awaiting_execution), failed_recent: numberOrZero(summary?.failed_recent), }, decisions: visibleDecisions, }); } function cleanDecisionText(value, maxLength = 2200) { return String(value ?? '') .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, '') .replace(/\r\n?/g, '\n') .replace(/[ \t]+/g, ' ') .replace(/\n{3,}/g, '\n\n') .trim() .slice(0, maxLength); } async function handleAdminDecisionResolve(request, env, user) { if (!isSameOriginRequest(request)) { return jsonResponse({ ok: false, error: 'REQUEST_REJECTED' }, 403); } const payload = await readJsonBody(request); const requestId = cleanText(payload?.request_id, 160); const action = cleanCode(payload?.action, 80); const inputText = cleanDecisionText( payload?.text || payload?.instruction, 2200 ); if (!requestId) throw new PublicError('REQUEST_ID_REQUIRED', 400); if (!action) throw new PublicError('DECISION_ACTION_REQUIRED', 400); const filter = stageFilter(user, 'd.stage'); const storedRow = await env.DB.prepare(` SELECT d.* FROM decision_requests d WHERE d.request_id = ?${filter.sql} LIMIT 1 `).bind(requestId, ...filter.params).first(); if (!storedRow) throw new PublicError('DECISION_NOT_FOUND', 404); const row = await hydrateDecisionRow(env, storedRow, true); if (row.status !== 'pending') { throw new PublicError('DECISION_ALREADY_PROCESSED', 409); } const now = Date.now(); if (Number(row.expires_at || 0) < now) { await expireDecisionRequests(env, now); throw new PublicError('DECISION_EXPIRED', 409); } const allowedActions = row.decision_type === 'call' ? new Set([...CALL_DECISION_ACTIONS, ...GENERAL_DECISION_ACTIONS]) : GENERAL_DECISION_ACTIONS; if (!allowedActions.has(action)) { throw new PublicError('DECISION_ACTION_INVALID', 400); } let resolutionText = inputText; let resolutionTimeAt = 0; if (row.decision_type === 'general' || GENERAL_DECISION_ACTIONS.has(action)) { if (action === 'send_recommendation') { resolutionText = cleanDecisionText(row.recommendation, 2200); if (!resolutionText) { throw new PublicError('RECOMMENDATION_EMPTY', 400); } } if ( ['ai_instruction', 'custom_text'].includes(action) && !resolutionText ) { throw new PublicError('DECISION_TEXT_REQUIRED', 400); } if (action === 'manager_manual' && !resolutionText) { resolutionText = 'Ответьте клиенту вручную'; } if (action === 'pause' && !resolutionText) { resolutionText = 'Поставить клиента на паузу до решения руководителя'; } } else { const proposedTime = numberOrZero(row.proposed_time_at); if (action === 'confirm_time') { if (!proposedTime) { throw new PublicError('PROPOSED_TIME_REQUIRED', 400); } resolutionTimeAt = proposedTime; resolutionText = resolutionText || 'Подтвердить предложенное время звонка'; } if (action === 'plus_2h') { resolutionTimeAt = Math.max(proposedTime || now, now) + 2 * 60 * 60 * 1000; resolutionText = resolutionText || 'Перенести звонок на два часа позже'; } if (action === 'custom_time') { resolutionTimeAt = safeTimestamp(payload?.time_at); if ( resolutionTimeAt < now + 5 * 60 * 1000 || resolutionTimeAt > now + 30 * 24 * 60 * 60 * 1000 ) { throw new PublicError('CUSTOM_TIME_INVALID', 400); } resolutionText = resolutionText || 'Согласовать указанное время звонка'; } if (action === 'decline' && !resolutionText) { resolutionText = 'Вежливо отказаться от звонка и продолжить переписку'; } if (action === 'manager_manual' && !resolutionText) { resolutionText = 'Менеджер самостоятельно согласует звонок с клиентом'; } } const isSiteTest = row.source === 'site_test'; const nextStatus = isSiteTest ? 'executed' : 'resolved'; const encrypted = await encryptDecisionSensitive(env, requestId, { reason: row.reason, manager_task: row.manager_task, recommendation: row.recommendation, context: parseDecisionContext(row.context_json), resolution_text: resolutionText, }); const updated = await env.DB.prepare(` UPDATE decision_requests SET status = ?, resolved_at = ?, resolved_by = ?, resolved_login = ?, resolution_action = ?, resolution_text = '', resolution_time_at = ?, expires_at = ?, executed_at = ?, execution_status = ?, execution_error = '', execution_attempts = 0, next_attempt_at = ?, lease_owner = '', lease_token_hash = '', lease_expires_at = 0, prepared_message_hash = '', prepared_at = 0, sensitive_ciphertext = ?, sensitive_iv = ?, sensitive_key_version = ?, sensitive_purged_at = 0, updated_at = ? WHERE request_id = ? AND status = 'pending' `).bind( nextStatus, now, Number(user.id), cleanText(user.login, 80), action, resolutionTimeAt, now + DECISION_EXECUTION_TTL_MS, isSiteTest ? now : 0, isSiteTest ? 'site_test_completed' : 'waiting_script', now, encrypted.sensitive_ciphertext, encrypted.sensitive_iv, encrypted.sensitive_key_version, now, requestId ).run(); if (!decisionChanges(updated)) { throw new PublicError('DECISION_ALREADY_PROCESSED', 409); } await recordDecisionEvent( env, requestId, isSiteTest ? 'test_resolved' : 'resolved', user, { action, time_at: resolutionTimeAt, decision_type: row.decision_type, } ); await writeAudit( env, user, 'decision_resolved', `${requestId}: ${action}`, request ); const result = await env.DB.prepare(` SELECT * FROM decision_requests WHERE request_id = ? `).bind(requestId).first(); return jsonResponse({ ok: true, server_time: now, decision: await decisionRowForUser(env, result, user, true), }); } async function handleAdminDecisionTest(request, env, user) { if (!isSameOriginRequest(request)) { return jsonResponse({ ok: false, error: 'REQUEST_REJECTED' }, 403); } const payload = await readJsonBody(request); const type = cleanText(payload?.decision_type || payload?.type, 30) .toLowerCase(); if (!DECISION_TYPE_VALUES.has(type)) { throw new PublicError('DECISION_TYPE_INVALID', 400); } const latest = await env.DB.prepare(` SELECT manager_id, manager_name, stage, platform FROM installations ORDER BY last_seen_at DESC LIMIT 1 `).first(); const now = Date.now(); const testId = crypto.randomUUID(); const callTime = now + 2 * 60 * 60 * 1000; const result = await createDecisionRequest(env, { request_id: `test-${testId}`, installation_id: `site-test-${user.id}`, client_key: `test-client-${testId}`, client_label: 'Тестовый клиент', manager_id: latest?.manager_id || 'test-manager', manager_name: latest?.manager_name || 'Тестовый менеджер', stage: latest?.stage || 'ТЕСТ', platform: latest?.platform || 'test', decision_type: type, reason: type === 'call' ? 'Клиент предложил созвон и ожидает решения по времени' : 'ИИ передал диалог руководителю: требуется решение по ответу', manager_task: type === 'call' ? 'Подтвердить, перенести или отклонить звонок' : 'Выбрать способ продолжения диалога', recommendation: type === 'call' ? 'Подтвердить предложенное время, если оно подходит' : 'Спасибо, что рассказал. Давай продолжим эту тему чуть позже, мне нужно уточнить один момент', proposed_time_at: type === 'call' ? callTime : 0, proposed_time_text: type === 'call' ? 'Через два часа, МСК' : '', context: [ { who: 'Клиент', text: type === 'call' ? 'Давай сегодня созвонимся, например через два часа?' : 'Можешь подробнее рассказать, как это работает?', at: now - 2 * 60 * 1000, }, { who: 'ИИ', text: 'Диалог остановлен до решения руководителя', at: now - 60 * 1000, }, ], }, 'site_test'); await writeAudit( env, user, 'decision_test_created', result.row.request_id, request ); return jsonResponse({ ok: true, server_time: now, decision: result.row, }); } // У новых и восстановленных установок manager_id постоянный и берётся из // manager_stages. Стейдж и имя оставлены только как мост для старых записей. // Платформа добавляется отдельно в PARTITION BY ниже. const CRM_MANAGER_KEY_SQL = ` CASE WHEN TRIM(COALESCE(i.manager_id, '')) <> '' THEN 'id:' || LOWER(TRIM(i.manager_id)) WHEN TRIM(COALESCE(i.stage, '')) <> '' THEN 'stage:' || LOWER(TRIM(i.stage)) WHEN TRIM(COALESCE(i.manager_name, '')) <> '' THEN 'name:' || LOWER(TRIM(i.manager_name)) ELSE 'installation:' || i.installation_id END `; function currentInstallationsCte(filter) { return ` WITH installation_candidates AS ( SELECT i.*, ${CRM_MANAGER_KEY_SQL} AS crm_manager_key, CASE WHEN i.last_seen_at > 0 AND EXISTS ( SELECT 1 FROM installation_credentials ic WHERE ic.installation_id = i.installation_id AND ic.status = 'active' ) THEN 1 ELSE 0 END AS crm_personal_active FROM installations i WHERE 1 = 1${filter.sql} ), ranked_installations AS ( SELECT candidate.*, ROW_NUMBER() OVER ( PARTITION BY candidate.crm_manager_key, LOWER(TRIM(COALESCE(candidate.platform, ''))) ORDER BY candidate.crm_personal_active DESC, candidate.last_seen_at DESC, candidate.updated_at DESC, candidate.installation_id DESC ) AS crm_installation_rank FROM installation_candidates candidate ), current_installations AS ( SELECT * FROM ranked_installations WHERE crm_installation_rank = 1 ) `; } function currentInstallationParams(filter) { return [...filter.params]; } function currentClientsCte(filter) { return ` ${currentInstallationsCte(filter)}, ranked_current_clients AS ( SELECT c.*, i.manager_id, i.manager_name, i.stage, i.last_seen_at AS installation_last_seen_at, i.online AS installation_online, i.crm_manager_key, ROW_NUMBER() OVER ( PARTITION BY i.crm_manager_key, LOWER(TRIM(COALESCE(c.platform, i.platform, ''))), c.client_key ORDER BY c.updated_at DESC, i.last_seen_at DESC, c.installation_id DESC ) AS crm_client_rank FROM client_states c INNER JOIN current_installations i ON i.installation_id = c.installation_id AND c.updated_at >= i.last_seen_at - ${CLIENT_SNAPSHOT_STALE_GRACE_MS} ), current_clients AS ( SELECT * FROM ranked_current_clients WHERE crm_client_rank = 1 ) `; } async function handleAdminSummary(env, user) { const now = Date.now(); const onlineAfter = now - ONLINE_WINDOW_MS; const backgroundAfter = now - BACKGROUND_WARNING_MS; const errorsAfter = now - 24 * 60 * 60 * 1000; const installationFilter = stageFilter(user, 'i.stage'); const clientFilter = stageFilter(user, 'i.stage'); const eventFilter = stageFilter(user, 'stage'); const managerFilter = stageFilter(user, 's.canonical_stage'); const installationCte = currentInstallationsCte(installationFilter); const clientCte = currentClientsCte(clientFilter); const installationParams = currentInstallationParams(installationFilter); const clientParams = currentInstallationParams(clientFilter); const [installations, clients, errors, managers] = await Promise.all([ env.DB.prepare(` ${installationCte} SELECT COUNT(*) AS installations_total, COUNT(DISTINCT crm_manager_key) AS managers_with_installations, SUM( CASE WHEN online = 1 AND last_seen_at >= ? THEN 1 ELSE 0 END ) AS installations_online, COUNT(DISTINCT CASE WHEN online = 1 AND last_seen_at >= ? THEN crm_manager_key ELSE NULL END) AS managers_online, SUM(CASE WHEN online = 1 AND last_seen_at >= ? AND page_visible = 0 AND page_hidden_since > 0 AND page_hidden_since <= ? THEN 1 ELSE 0 END) AS installations_background, SUM(clients_total) AS reported_clients_total, SUM(clients_active) AS reported_clients_active FROM current_installations `).bind( ...installationParams, onlineAfter, onlineAfter, onlineAfter, backgroundAfter ).first(), env.DB.prepare(` ${clientCte} SELECT COUNT(*) AS clients_total, SUM(CASE WHEN c.enabled = 1 THEN 1 ELSE 0 END) AS clients_enabled, SUM( CASE WHEN c.enabled = 1 AND c.on_call = 0 AND c.awaiting_human = 0 AND c.installation_online = 1 AND c.installation_last_seen_at >= ? THEN 1 ELSE 0 END ) AS clients_active_now, SUM(CASE WHEN c.on_call = 1 THEN 1 ELSE 0 END) AS clients_on_call, SUM(CASE WHEN c.awaiting_human = 1 THEN 1 ELSE 0 END) AS clients_human, SUM(CASE WHEN c.status = 'waiting_ai' THEN 1 ELSE 0 END) AS waiting_ai, SUM(CASE WHEN c.status = 'waiting_client' THEN 1 ELSE 0 END) AS waiting_client, MAX( CASE WHEN c.status = 'waiting_ai' THEN c.unanswered_since ELSE 0 END ) AS newest_unanswered_since, MIN( CASE WHEN c.status = 'waiting_ai' AND c.unanswered_since > 0 THEN c.unanswered_since ELSE NULL END ) AS oldest_unanswered_since FROM current_clients c `).bind(...clientParams, onlineAfter).first(), canSeeErrors(user) ? env.DB.prepare(` SELECT COUNT(*) AS errors_24h FROM event_logs WHERE created_at >= ? AND (level IN ('warn', 'error') OR code <> '')${eventFilter.sql} `).bind(errorsAfter, ...eventFilter.params).first() : Promise.resolve({ errors_24h: 0 }), env.DB.prepare(` SELECT COUNT(*) AS managers_total FROM manager_stages s WHERE s.active = 1${managerFilter.sql} `).bind(...managerFilter.params).first(), ]); const globalStop = await readGlobalStop(env); const latestVersions = await readLatestVersions(env); const restartReminder = await readRestartReminder(env); const loaderUrls = await readLoaderUrls(env); return jsonResponse({ ok: true, server_time: now, latest_versions: latestVersions, restart_reminder: restartReminder, loader_urls: loaderUrls, online_window_ms: ONLINE_WINDOW_MS, background_warning_ms: BACKGROUND_WARNING_MS, global_stop: globalStop, summary: { managers_total: numberOrZero(managers?.managers_total), managers_online: numberOrZero(installations?.managers_online), installations_background: numberOrZero(installations?.installations_background), installations_total: numberOrZero(installations?.installations_total), installations_online: numberOrZero(installations?.installations_online), reported_clients_total: numberOrZero(installations?.reported_clients_total), reported_clients_active: numberOrZero(installations?.reported_clients_active), clients_total: numberOrZero(clients?.clients_total), clients_enabled: numberOrZero(clients?.clients_enabled), clients_active_now: numberOrZero(clients?.clients_active_now), clients_on_call: numberOrZero(clients?.clients_on_call), clients_human: numberOrZero(clients?.clients_human), waiting_ai: numberOrZero(clients?.waiting_ai), waiting_client: numberOrZero(clients?.waiting_client), oldest_unanswered_since: numberOrZero(clients?.oldest_unanswered_since), errors_24h: numberOrZero(errors?.errors_24h), }, }); } async function handleAdminInstallations(env, user) { // В CRM отдаём только последнюю установку каждой платформы менеджера. // Число клиентов берём из той же авторитетной выборки, что используется // подробной таблицей и глобальными карточками. const filter = stageFilter(user, 'i.stage'); const managerFilter = stageFilter(user, 's.canonical_stage'); const currentCte = currentClientsCte(filter); const params = currentInstallationParams(filter); const [result, managers] = await Promise.all([ env.DB.prepare(` ${currentCte}, current_client_counts AS ( SELECT c.installation_id, COUNT(*) AS exact_clients_total, SUM(CASE WHEN c.enabled = 1 THEN 1 ELSE 0 END) AS exact_clients_active FROM current_clients c GROUP BY c.installation_id ) SELECT i.installation_id, i.manager_id, i.manager_name, i.stage, i.platform, i.script_version, i.state, i.paused, i.busy, i.online, i.page_visible, i.page_hidden_since, COALESCE(cc.exact_clients_total, 0) AS clients_total, COALESCE(cc.exact_clients_active, 0) AS clients_active, i.clients_total AS reported_clients_total, i.clients_active AS reported_clients_active, i.started_at, i.last_seen_at, i.last_reason, i.updated_at, COALESCE(s.stopped, 0) AS admin_stopped FROM installations i INNER JOIN current_installations ci ON ci.installation_id = i.installation_id LEFT JOIN current_client_counts cc ON cc.installation_id = i.installation_id LEFT JOIN installation_stops s ON s.installation_id = i.installation_id ORDER BY i.last_seen_at DESC LIMIT 500 `).bind(...params).all(), env.DB.prepare(` SELECT s.manager_id, s.canonical_stage, s.canonical_stage AS stage, s.created_at, s.updated_at FROM manager_stages s WHERE s.active = 1${managerFilter.sql} ORDER BY s.canonical_stage COLLATE NOCASE LIMIT 1000 `).bind(...managerFilter.params).all(), ]); return jsonResponse({ ok: true, server_time: Date.now(), background_warning_ms: BACKGROUND_WARNING_MS, managers: managers.results || [], installations: (result.results || []).map((row) => { if (canSeeErrors(user)) return row; return { ...row, last_reason: '' }; }), }); } async function handleAdminClients(env, url, user) { const limit = clampInt(url.searchParams.get('limit') || 500, 1, 1000); const filter = stageFilter(user, 'i.stage'); const currentCte = currentClientsCte(filter); const params = currentInstallationParams(filter); const result = await env.DB.prepare(` ${currentCte} SELECT c.installation_id, c.client_key, c.client_label, c.platform, c.enabled, c.status, c.on_call, c.awaiting_human, c.last_in_at, c.last_out_at, c.last_sender, c.unanswered_since, c.next_action_at, c.last_success_at, c.last_error_code, c.last_trace_id, c.updated_at, c.manager_id, c.manager_name, c.stage, c.installation_last_seen_at, p.memory_key, p.incomplete AS memory_incomplete, (SELECT GROUP_CONCAT(platform, ',') FROM (SELECT DISTINCT ml.platform FROM memory_chat_links ml WHERE ml.manager_id=c.manager_id AND ml.person_id=p.person_id ORDER BY ml.platform)) AS memory_platforms FROM current_clients c LEFT JOIN memory_chat_links l ON l.manager_id=c.manager_id AND l.platform=c.platform AND l.client_key=c.client_key LEFT JOIN memory_people p ON p.manager_id=l.manager_id AND p.person_id=l.person_id ORDER BY CASE c.status WHEN 'waiting_ai' THEN 0 WHEN 'human' THEN 1 WHEN 'error' THEN 2 WHEN 'call' THEN 3 ELSE 4 END, CASE WHEN c.unanswered_since > 0 THEN c.unanswered_since ELSE c.updated_at END ASC LIMIT ? `).bind(...params, limit).all(); return jsonResponse({ ok: true, server_time: Date.now(), clients: (result.results || []).map((row) => { row = {...row, memory_key:row.memory_key || '', memory_platforms:row.memory_platforms ? row.memory_platforms.split(',') : [], memory_incomplete:row.memory_key ? Boolean(row.memory_incomplete) : null}; if (canSeeErrors(user)) return row; return { ...row, last_error_code: '', last_trace_id: '', }; }), }); } async function handleAdminEvents(env, url, user) { const limit = clampInt(url.searchParams.get('limit') || 300, 1, 1000); const hours = clampInt(url.searchParams.get('hours') || 24, 1, 24 * 30); const after = Date.now() - hours * 60 * 60 * 1000; const filter = stageFilter(user, 'e.stage'); const result = await env.DB.prepare(` SELECT e.id, e.event_id, e.trace_id, e.installation_id, e.client_key, e.manager_id, e.stage, e.platform, e.script_version, e.event_type, e.step, e.level, e.code, e.message, e.details_json, e.created_at, e.received_at, COALESCE(c.client_label, '') AS client_label, COALESCE(i.manager_name, '') AS manager_name FROM event_logs e LEFT JOIN client_states c ON c.installation_id = e.installation_id AND c.client_key = e.client_key LEFT JOIN installations i ON i.installation_id = e.installation_id WHERE e.created_at >= ? AND (e.level IN ('warn', 'error') OR e.code <> '')${filter.sql} ORDER BY e.received_at DESC, e.created_at DESC LIMIT ? `).bind(after, ...filter.params, limit).all(); return jsonResponse({ ok: true, server_time: Date.now(), events: result.results || [], }); } async function handleAdminExport(env, url, user) { const hours = clampInt(url.searchParams.get('hours') || 24, 1, 24 * 30); const after = Date.now() - hours * 60 * 60 * 1000; const filter = stageFilter(user, 'stage'); const clientFilter = stageFilter(user, 'i.stage'); const eventFilter = stageFilter(user, 'e.stage'); const decisionFilter = stageFilter(user, 'd.stage'); const [installations, clients, events, decisions, decisionEvents] = await Promise.all([ env.DB.prepare(` SELECT * FROM installations WHERE 1 = 1${filter.sql} ORDER BY last_seen_at DESC `).bind(...filter.params).all(), env.DB.prepare(` SELECT c.* FROM client_states c LEFT JOIN installations i ON i.installation_id = c.installation_id WHERE 1 = 1${clientFilter.sql} ORDER BY c.updated_at DESC `).bind(...clientFilter.params).all(), canSeeErrors(user) ? env.DB.prepare(` SELECT e.*, COALESCE(c.client_label, '') AS client_label, COALESCE(i.manager_name, '') AS manager_name FROM event_logs e LEFT JOIN client_states c ON c.installation_id = e.installation_id AND c.client_key = e.client_key LEFT JOIN installations i ON i.installation_id = e.installation_id WHERE e.created_at >= ?${eventFilter.sql} ORDER BY e.received_at DESC, e.created_at DESC LIMIT 5000 `).bind(after, ...eventFilter.params).all() : Promise.resolve({ results: [] }), env.DB.prepare(` SELECT d.* FROM decision_requests d WHERE d.updated_at >= ?${decisionFilter.sql} ORDER BY d.updated_at DESC LIMIT 5000 `).bind(after, ...decisionFilter.params).all(), env.DB.prepare(` SELECT de.* FROM decision_events de LEFT JOIN decision_requests d ON d.request_id = de.request_id WHERE de.at >= ?${decisionFilter.sql} ORDER BY de.at DESC LIMIT 10000 `).bind(after, ...decisionFilter.params).all(), ]); const exportedDecisions = await Promise.all( (decisions.results || []).map(async (row) => { const hydrated = await hydrateDecisionRow(env, row); return publicDecisionRow(hydrated, true); }) ); const body = JSON.stringify( { format: 'ai-assistant-diagnostics-v1', generated_at: Date.now(), generated_at_msk: new Date().toLocaleString('ru-RU', { timeZone: 'Europe/Moscow', }), hours, installations: (installations.results || []).map((row) => { if (canSeeErrors(user)) return row; return { ...row, last_reason: '' }; }), clients: (clients.results || []).map((row) => { if (canSeeErrors(user)) return row; return { ...row, last_error_code: '', last_trace_id: '' }; }), events: events.results || [], decisions: exportedDecisions, decision_events: decisionEvents.results || [], }, null, 2 ); const stamp = new Date().toISOString().replace(/[:.]/g, '-'); return new Response(body, { status: 200, headers: { 'Content-Type': 'application/json; charset=utf-8', 'Content-Disposition': `attachment; filename="ai-monitor-diagnostics-${stamp}.json"`, 'Cache-Control': 'no-store', ...securityHeaders(), }, }); } // ================================================================ // GROQ AUDIO GATEWAY // Полученное аудио существует только в памяти текущего запроса. // Ни бинарные данные, ни текст расшифровки в D1 и Worker Logs не пишутся. // ================================================================ const AUDIO_MEDIA_TYPES = new Set([ 'audio/ogg', 'application/ogg', 'audio/opus', 'audio/webm', 'video/webm', 'audio/mpeg', 'audio/mp3', 'audio/mp4', 'video/mp4', 'audio/x-m4a', 'audio/wav', 'audio/x-wav', 'application/octet-stream', ]); async function handleAudioTranscription( request, env, identity, requestId ) { if (!env.GROQ_API_KEY) { throw new PublicError('AUDIO_NOT_CONFIGURED', 503); } const declaredLength = Number( request.headers.get('Content-Length') || 0 ); if (declaredLength > MAX_AUDIO_BODY_BYTES) { throw new PublicError('AUDIO_TOO_LARGE', 413); } const rawType = cleanText( request.headers.get('Content-Type'), 100 ).split(';')[0].trim().toLowerCase(); const mediaType = AUDIO_MEDIA_TYPES.has(rawType) ? rawType : 'application/octet-stream'; const body = await request.arrayBuffer(); if (body.byteLength < 200) { throw new PublicError('AUDIO_EMPTY', 400); } if (body.byteLength > MAX_AUDIO_BODY_BYTES) { throw new PublicError('AUDIO_TOO_LARGE', 413); } const requestedName = cleanText( request.headers.get('X-Audio-Filename'), 100 ); const fileName = safeAudioFileName(requestedName, mediaType); const startedAt = Date.now(); let upstreamResponse; try { upstreamResponse = await fetchGroqTranscriptionWithRetry( env, body, mediaType, fileName ); } catch (error) { console.error('audio_gateway_network_failed', { request_id: requestId, installation_id: identity.installation_id, bytes: body.byteLength, latency_ms: Date.now() - startedAt, name: cleanText(error?.name || 'Error', 80), }); return audioGatewayErrorResponse( 'AUDIO_UPSTREAM_UNAVAILABLE', 503, requestId ); } let upstreamData = null; try { const responseText = await upstreamResponse.text(); upstreamData = responseText ? JSON.parse(responseText) : null; } catch (_) { upstreamData = null; } if (!upstreamResponse.ok || !upstreamData) { const mapped = mapGroqAudioError(upstreamResponse.status); console.warn('audio_gateway_upstream_error', { request_id: requestId, installation_id: identity.installation_id, upstream_status: Number(upstreamResponse.status || 0), bytes: body.byteLength, latency_ms: Date.now() - startedAt, }); return audioGatewayErrorResponse( mapped.code, mapped.status, requestId ); } const text = String(upstreamData.text || '').trim(); if (!text) { return audioGatewayErrorResponse( 'AUDIO_EMPTY_TRANSCRIPT', 502, requestId ); } return jsonResponse( { ok: true, text: text.slice(0, 50000), gateway_request_id: requestId, }, 200, true ); } function safeAudioFileName(value, mediaType) { const cleaned = String(value || '') .replace(/[^a-zA-Z0-9._-]/g, '') .slice(0, 80); if (cleaned && /\.[a-zA-Z0-9]{2,5}$/.test(cleaned)) { return cleaned; } const type = String(mediaType || '').toLowerCase(); if (type.includes('webm')) return 'voice.webm'; if (type.includes('mpeg') || type.includes('mp3')) return 'voice.mp3'; if (type.includes('mp4') || type.includes('m4a')) return 'voice.m4a'; if (type.includes('wav')) return 'voice.wav'; return 'voice.ogg'; } async function fetchGroqTranscriptionWithRetry( env, body, mediaType, fileName ) { let lastError = null; for (let attempt = 1; attempt <= 2; attempt += 1) { const form = new FormData(); form.append( 'file', new Blob([body], { type: mediaType }), fileName ); form.append('model', 'whisper-large-v3'); form.append('language', 'ru'); form.append('response_format', 'json'); const controller = new AbortController(); const timeoutId = setTimeout( () => controller.abort(), 90000 ); try { const response = await fetch( 'https://api.groq.com/openai/v1/audio/transcriptions', { method: 'POST', headers: { Authorization: `Bearer ${String(env.GROQ_API_KEY)}`, }, body: form, signal: controller.signal, } ); clearTimeout(timeoutId); if ( attempt < 2 && [429, 500, 502, 503, 504].includes(response.status) ) { try { await response.body?.cancel(); } catch (_) {} await aiDelay(1500); continue; } return response; } catch (error) { clearTimeout(timeoutId); lastError = error; if (attempt < 2 && error?.name !== 'AbortError') { await aiDelay(1500); continue; } throw error; } } throw lastError || new Error('AUDIO_UPSTREAM_UNAVAILABLE'); } function mapGroqAudioError(status) { if (status === 400) return { code: 'AUDIO_REJECTED', status: 400 }; if (status === 413) return { code: 'AUDIO_TOO_LARGE', status: 413 }; if (status === 429) return { code: 'AUDIO_PROVIDER_RATE_LIMITED', status: 429 }; if (status === 401 || status === 403) { return { code: 'AUDIO_PROVIDER_AUTH_FAILED', status: 503 }; } if (status >= 500) { return { code: 'AUDIO_UPSTREAM_UNAVAILABLE', status: 503 }; } return { code: 'AUDIO_UPSTREAM_ERROR', status: 502 }; } function audioGatewayErrorResponse(code, status, requestId) { return jsonResponse( { ok: false, error: code, request_id: requestId, }, status, true ); } // ================================================================ // ANTHROPIC API GATEWAY // Содержимое system/messages только проксируется в Anthropic и никогда // не записывается в D1. В БД сохраняются лишь токены, стоимость и коды ошибок. // ================================================================ const AI_DIALOGUE_OUTPUT_SCHEMA = { type: 'object', additionalProperties: false, required: [ 'thoughts', 'action', 'human_type', 'messages', 'manager_task', 'call', 'photos', ], properties: { thoughts: { type: 'string' }, action: { type: 'string', enum: ['reply', 'clarify', 'human', 'skip'], }, human_type: { type: 'string', enum: ['general', 'call_decision'], }, messages: { type: 'array', description: '0 messages for human/skip, 1 for clarify, 1 to 4 for reply. Keep every message compact.', items: { type: 'string', }, }, manager_task: { type: 'string' }, call: { type: 'object', additionalProperties: false, required: ['initiator', 'proposed_time'], properties: { initiator: { type: 'string', enum: ['client', 'assistant'], }, proposed_time: { type: 'string' }, }, }, photos: { type: 'array', items: { type: 'string' }, }, }, }; const AI_TASK_CONFIG = { dialogue: { defaultMaxTokens: 1600, maxTokens: 2600, effort: 'medium', systemMaxChars: 280000, userMaxChars: 420000, }, dossier: { defaultMaxTokens: 1600, maxTokens: 2200, effort: 'low', systemMaxChars: 60000, userMaxChars: 420000, }, vision: { defaultMaxTokens: 700, maxTokens: 1000, effort: 'low', systemMaxChars: 20000, userMaxChars: 30000, }, }; const AI_IMAGE_MEDIA_TYPES = new Set([ 'image/jpeg', 'image/png', 'image/webp', 'image/gif', ]); async function handleAiGatewayMessage( env, payload, identity, requestId ) { if (!env.ANTHROPIC_API_KEY) { throw new PublicError('AI_NOT_CONFIGURED', 503); } const normalized = normalizeAiGatewayPayload(payload, env); await enforceAiDailyBudget(env, identity); const startedAt = Date.now(); let upstreamResponse; try { upstreamResponse = await fetchAnthropicWithRetry( env, normalized.body ); } catch (error) { await safeRecordAiUsage(env, { identity, requestId, task: normalized.task, model: normalized.model, status: 'network_error', upstreamStatus: 0, errorCode: 'AI_UPSTREAM_UNAVAILABLE', latencyMs: Date.now() - startedAt, }); console.error('ai_gateway_network_failed', { request_id: requestId, installation_id: identity.installation_id, task: normalized.task, name: cleanText(error?.name || 'Error', 80), }); return aiGatewayErrorResponse( 'AI_UPSTREAM_UNAVAILABLE', 503, requestId ); } const upstreamStatus = Number(upstreamResponse.status || 0); let upstreamData = null; try { const responseText = await upstreamResponse.text(); upstreamData = responseText ? JSON.parse(responseText) : null; } catch (_) { upstreamData = null; } if (!upstreamResponse.ok || !upstreamData) { const mapped = mapAnthropicGatewayError( upstreamStatus, upstreamData?.error?.type ); await safeRecordAiUsage(env, { identity, requestId, task: normalized.task, model: normalized.model, status: 'upstream_error', upstreamStatus, errorCode: mapped.code, latencyMs: Date.now() - startedAt, usage: upstreamData?.usage, }); console.warn('ai_gateway_upstream_error', { request_id: requestId, installation_id: identity.installation_id, task: normalized.task, upstream_status: upstreamStatus, upstream_type: cleanCode(upstreamData?.error?.type, 80), }); return aiGatewayErrorResponse( mapped.code, mapped.status, requestId ); } await safeRecordAiUsage(env, { identity, requestId, task: normalized.task, model: cleanText(upstreamData.model || normalized.model, 80), status: 'ok', upstreamStatus, errorCode: '', latencyMs: Date.now() - startedAt, usage: upstreamData.usage, }); // Новые клиенты читают messages[]. Для плавного обновления профилей // сервер также добавляет вычисленное поле message старого формата. const compatibleData = addLegacyDialogueMessageField( upstreamData, normalized.task ); // Формат успешного ответа сохраняем совместимым с Anthropic Messages API. // Клиенту добавляется только безопасный идентификатор шлюза. return jsonResponse( { ...compatibleData, gateway_request_id: requestId, }, 200, true ); } function addLegacyDialogueMessageField(upstreamData, task) { if (task !== 'dialogue' || !Array.isArray(upstreamData?.content)) { return upstreamData; } const content = upstreamData.content.map((block) => { if ( !block || block.type !== 'text' || typeof block.text !== 'string' ) { return block; } let parsed; try { parsed = JSON.parse(block.text); } catch (_) { return block; } if ( !parsed || typeof parsed !== 'object' || Array.isArray(parsed) || !Array.isArray(parsed.messages) ) { return block; } const messages = parsed.messages .filter((value) => typeof value === 'string') .map((value) => value.trim()) .filter(Boolean) .slice(0, 4); return { ...block, text: JSON.stringify({ ...parsed, messages, message: messages.join(' '), }), }; }); return { ...upstreamData, content }; } function normalizeAiGatewayPayload(payload, env) { if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { throw new PublicError('AI_INVALID_PAYLOAD', 400); } const task = cleanCode(payload.task, 30).toLowerCase(); if (!AI_TASK_VALUES.has(task)) { throw new PublicError('AI_TASK_NOT_ALLOWED', 400); } const model = cleanText( env.ANTHROPIC_MODEL || AI_DEFAULT_MODEL, 80 ); if (!AI_ALLOWED_MODELS.has(model)) { throw new PublicError('AI_MODEL_NOT_ALLOWED', 503); } const config = AI_TASK_CONFIG[task]; const requestedMaxTokens = Number(payload.max_tokens || 0); const maxTokens = requestedMaxTokens > 0 ? Math.min( config.maxTokens, Math.max(64, Math.floor(requestedMaxTokens)) ) : config.defaultMaxTokens; const system = normalizeAiSystem(payload.system, task, config); const messages = normalizeAiMessages(payload.messages, task, config); const body = { model, max_tokens: maxTokens, thinking: { type: 'disabled' }, messages, output_config: task === 'dialogue' ? { effort: 'medium', format: { type: 'json_schema', schema: AI_DIALOGUE_OUTPUT_SCHEMA, }, } : { effort: config.effort }, }; if (system.length) body.system = system; return { task, model, body }; } function normalizeAiSystem(value, task, config) { if (value === undefined || value === null || value === '') { if (task === 'vision') return []; throw new PublicError('AI_SYSTEM_REQUIRED', 400); } const rawBlocks = typeof value === 'string' ? [{ type: 'text', text: value }] : value; if (!Array.isArray(rawBlocks) || rawBlocks.length > 4) { throw new PublicError('AI_INVALID_SYSTEM', 400); } let totalChars = 0; const blocks = []; for (const raw of rawBlocks) { if (!raw || raw.type !== 'text' || typeof raw.text !== 'string') { throw new PublicError('AI_INVALID_SYSTEM', 400); } const text = cleanAiPromptText(raw.text); if (!text.trim()) continue; totalChars += text.length; if (totalChars > config.systemMaxChars) { throw new PublicError('AI_SYSTEM_TOO_LARGE', 413); } blocks.push({ type: 'text', text }); } if (!blocks.length && task !== 'vision') { throw new PublicError('AI_SYSTEM_REQUIRED', 400); } // Кэшируем только первый статичный блок: легенду, каталог и техпромпт. // Следующие блоки могут содержать профиль конкретного менеджера. if (blocks.length && task !== 'vision') { blocks[0].cache_control = { type: 'ephemeral', ttl: '1h', }; } return blocks; } function normalizeAiMessages(value, task, config) { if (!Array.isArray(value) || value.length !== 1) { throw new PublicError('AI_ONE_USER_MESSAGE_REQUIRED', 400); } const message = value[0]; if (!message || message.role !== 'user') { throw new PublicError('AI_ONE_USER_MESSAGE_REQUIRED', 400); } if (task !== 'vision') { if (typeof message.content !== 'string') { throw new PublicError('AI_TEXT_CONTENT_REQUIRED', 400); } const content = cleanAiPromptText(message.content); if (!content.trim()) { throw new PublicError('AI_TEXT_CONTENT_REQUIRED', 400); } if (content.length > config.userMaxChars) { throw new PublicError('AI_USER_CONTENT_TOO_LARGE', 413); } return [{ role: 'user', content }]; } return [{ role: 'user', content: normalizeAiVisionContent(message.content, config), }]; } function normalizeAiVisionContent(value, config) { if (!Array.isArray(value) || !value.length || value.length > 4) { throw new PublicError('AI_INVALID_VISION_CONTENT', 400); } const content = []; let imageCount = 0; let imageBase64Chars = 0; let textChars = 0; for (const block of value) { if (block?.type === 'text') { if (typeof block.text !== 'string') { throw new PublicError('AI_INVALID_VISION_TEXT', 400); } const text = cleanAiPromptText(block.text); textChars += text.length; if (textChars > config.userMaxChars) { throw new PublicError('AI_VISION_TEXT_TOO_LARGE', 413); } content.push({ type: 'text', text }); continue; } if (block?.type !== 'image') { throw new PublicError('AI_INVALID_VISION_CONTENT', 400); } const source = block.source; const mediaType = cleanText(source?.media_type, 40).toLowerCase(); const data = source?.data; if ( !source || source.type !== 'base64' || !AI_IMAGE_MEDIA_TYPES.has(mediaType) || typeof data !== 'string' || !data.length ) { throw new PublicError('AI_INVALID_IMAGE', 400); } imageCount += 1; imageBase64Chars += data.length; // Две версии одного фото: обычная и контрастная для OCR. if ( imageCount > 2 || data.length > 14 * 1024 * 1024 || imageBase64Chars > 28 * 1024 * 1024 ) { throw new PublicError('AI_IMAGE_TOO_LARGE', 413); } content.push({ type: 'image', source: { type: 'base64', media_type: mediaType, data, }, }); } if (!imageCount || !textChars) { throw new PublicError('AI_IMAGE_AND_QUESTION_REQUIRED', 400); } return content; } function cleanAiPromptText(value) { return String(value ?? '').replace( /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, '' ); } async function fetchAnthropicWithRetry(env, body) { let lastError = null; for (let attempt = 1; attempt <= 2; attempt += 1) { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 90000); try { const response = await fetch( 'https://api.anthropic.com/v1/messages', { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-api-key': String(env.ANTHROPIC_API_KEY), 'anthropic-version': '2023-06-01', }, body: JSON.stringify(body), signal: controller.signal, } ); clearTimeout(timeoutId); if ( attempt < 2 && [429, 500, 502, 503, 529].includes(response.status) ) { try { await response.body?.cancel(); } catch (_) { // Ничего: тело первой неудачной попытки клиенту не отдаётся. } const retryAfter = Number(response.headers.get('Retry-After') || 0); const waitMs = retryAfter > 0 ? Math.min(5000, retryAfter * 1000) : 1500; await aiDelay(waitMs); continue; } return response; } catch (error) { clearTimeout(timeoutId); lastError = error; if (attempt < 2 && error?.name !== 'AbortError') { await aiDelay(1500); continue; } throw error; } } throw lastError || new Error('AI_UPSTREAM_UNAVAILABLE'); } function aiDelay(milliseconds) { return new Promise((resolve) => setTimeout(resolve, milliseconds)); } function mapAnthropicGatewayError(status, upstreamType) { if (status === 400) return { code: 'AI_REQUEST_REJECTED', status: 400 }; if (status === 413) return { code: 'AI_REQUEST_TOO_LARGE', status: 413 }; if (status === 429) return { code: 'AI_PROVIDER_RATE_LIMITED', status: 429 }; if (status === 529) return { code: 'AI_OVERLOADED', status: 503 }; if (status === 401 || status === 403) { return { code: 'AI_PROVIDER_AUTH_FAILED', status: 503 }; } if (status >= 500) return { code: 'AI_UPSTREAM_UNAVAILABLE', status: 503 }; const safeType = cleanCode(upstreamType, 60); return { code: safeType ? `AI_${safeType.toUpperCase()}` : 'AI_UPSTREAM_ERROR', status: 502, }; } function aiGatewayErrorResponse(code, status, requestId) { return jsonResponse( { type: 'error', error: { type: code, message: code, }, request_id: requestId, }, status, true ); } function aiInstallationDailyLimitUsd(env) { return aiLimitNumber( env.AI_INSTALLATION_DAILY_LIMIT_USD, AI_DEFAULT_INSTALLATION_DAILY_LIMIT_USD ); } function aiGlobalDailyLimitUsd(env) { return aiLimitNumber( env.AI_GLOBAL_DAILY_LIMIT_USD, AI_DEFAULT_GLOBAL_DAILY_LIMIT_USD ); } function aiOkzDailyLimitUsd(env) { return aiLimitNumber( env.OKZ_AI_DAILY_LIMIT_USD, AI_DEFAULT_OKZ_DAILY_LIMIT_USD ); } function aiLimitNumber(value, fallback) { if (value === undefined || value === null || value === '') return fallback; const parsed = Number(value); if (!Number.isFinite(parsed) || parsed < 0) return fallback; return Math.min(100000, parsed); } async function enforceAiDailyBudget(env, identity) { const usageDay = moscowDayKey(Date.now()); const totals = await env.DB.prepare(` SELECT COALESCE(SUM(estimated_cost_microusd), 0) AS global_cost, COALESCE(SUM( CASE WHEN installation_id = ? THEN estimated_cost_microusd ELSE 0 END ), 0) AS installation_cost FROM ai_usage WHERE usage_day = ? AND status = 'ok' `).bind(identity.installation_id, usageDay).first(); const installationLimit = aiInstallationDailyLimitUsd(env); const globalLimit = aiGlobalDailyLimitUsd(env); const installationSpent = numberOrZero(totals?.installation_cost); const globalSpent = numberOrZero(totals?.global_cost); if ( installationLimit > 0 && installationSpent >= Math.round(installationLimit * 1000000) ) { throw new PublicError('AI_INSTALLATION_DAILY_BUDGET_REACHED', 429); } if ( globalLimit > 0 && globalSpent >= Math.round(globalLimit * 1000000) ) { throw new PublicError('AI_GLOBAL_DAILY_BUDGET_REACHED', 429); } } async function createOkzPublicIdentity(request) { const networkSource = [ cleanText(request.headers.get('CF-Connecting-IP'), 60) || 'unknown-ip', cleanText(request.headers.get('User-Agent'), 200) || 'unknown-agent', ].join('|'); const fingerprint = (await sha256Hex(networkSource)).slice(0, 32); return { auth_mode: 'okz_public', credential_id: `okz_${fingerprint}`, installation_id: `okz_public_${fingerprint}`, manager_id: 'okz', manager_name: 'OKZ', stage: 'okz', platform: 'okz', }; } async function enforceOkzDailyBudget(env, identity) { const usageDay = moscowDayKey(Date.now()); const row = await env.DB.prepare(` SELECT COALESCE(SUM(estimated_cost_microusd), 0) AS cost FROM ai_usage WHERE usage_day = ? AND installation_id = ? AND status = 'ok' `).bind(usageDay, identity.installation_id).first(); const limitUsd = aiOkzDailyLimitUsd(env); if ( limitUsd > 0 && numberOrZero(row?.cost) >= Math.round(limitUsd * 1000000) ) { throw new PublicError('OKZ_AI_DAILY_BUDGET_REACHED', 429); } } async function safeRecordAiUsage(env, data) { try { const usage = data.usage || {}; const inputTokens = aiUsageInteger(usage.input_tokens); const cacheCreationTokens = aiUsageInteger( usage.cache_creation_input_tokens ); const cacheReadTokens = aiUsageInteger( usage.cache_read_input_tokens ); const outputTokens = aiUsageInteger(usage.output_tokens); const estimatedCost = estimateAiCostMicroUsd({ inputTokens, cacheCreationTokens, cacheReadTokens, outputTokens, }); const now = Date.now(); await env.DB.prepare(` INSERT OR REPLACE INTO ai_usage ( usage_day, installation_id, request_id, credential_id, manager_id, stage, platform, task, model, status, input_tokens, cache_creation_input_tokens, cache_read_input_tokens, output_tokens, estimated_cost_microusd, upstream_status, error_code, latency_ms, created_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `).bind( moscowDayKey(now), data.identity.installation_id, cleanText(data.requestId, 100), cleanText(data.identity.credential_id, 120), cleanText(data.identity.manager_id, 120), cleanText(data.identity.stage, 120), cleanText(data.identity.platform, 40), cleanCode(data.task, 30), cleanText(data.model, 80), cleanCode(data.status, 40), inputTokens, cacheCreationTokens, cacheReadTokens, outputTokens, estimatedCost, Math.max(0, Math.floor(Number(data.upstreamStatus || 0))), cleanCode(data.errorCode, 100), Math.max(0, Math.floor(Number(data.latencyMs || 0))), now ).run(); } catch (error) { console.error('ai_usage_write_failed', { request_id: cleanText(data.requestId, 100), installation_id: cleanText(data.identity?.installation_id, 160), name: cleanText(error?.name || 'Error', 80), }); } } function aiUsageInteger(value) { const parsed = Number(value || 0); if (!Number.isFinite(parsed) || parsed < 0) return 0; return Math.min(1000000000, Math.floor(parsed)); } function estimateAiCostMicroUsd({ inputTokens, cacheCreationTokens, cacheReadTokens, outputTokens, }) { // Консервативная цена Sonnet: обычный вход $3/MTok, // запись 1h $6/MTok, чтение кэша $0.30/MTok, выход $15/MTok. return Math.max(0, Math.round( inputTokens * 3 + cacheCreationTokens * 6 + cacheReadTokens * 0.3 + outputTokens * 15 )); } function moscowDayKey(timestamp) { // Москва круглый год UTC+3 без перехода на летнее время. return new Date(Number(timestamp || Date.now()) + 3 * 60 * 60 * 1000) .toISOString() .slice(0, 10); } async function enforceRateLimit(binding, key, errorCode = 'RATE_LIMITED') { if (!binding || typeof binding.limit !== 'function') return; const result = await binding.limit({ key: cleanText(key, 64) || 'unknown', }); if (!result?.success) { throw new PublicError(errorCode, 429); } } function randomOpaqueSecret(byteLength = 32) { return bytesToBase64Url( crypto.getRandomValues(new Uint8Array(byteLength)) ); } function parseScopedToken(token, expectedPrefix) { const parts = String(token || '').split('.'); if ( parts.length !== 3 || parts[0] !== expectedPrefix || !/^[a-zA-Z0-9_-]{8,120}$/.test(parts[1]) || !/^[a-zA-Z0-9_-]{20,200}$/.test(parts[2]) ) { return null; } return { id: parts[1], secret: parts[2], token: parts.join('.') }; } async function authenticateInstallation(request, env) { const parsed = parseScopedToken( bearerToken(request), INSTALLATION_TOKEN_PREFIX ); if (!parsed) return null; const row = await env.DB.prepare(` SELECT * FROM installation_credentials WHERE credential_id = ? AND status = 'active' `).bind(parsed.id).first(); if (!row) return null; const providedHash = await sha256Hex(parsed.token); if (!safeEqual(providedHash, row.token_hash)) return null; const now = Date.now(); if (now - Number(row.last_used_at || 0) >= 5 * 60 * 1000) { await env.DB.prepare(` UPDATE installation_credentials SET last_used_at = ?, last_ip = ?, last_user_agent = ?, updated_at = ? WHERE credential_id = ? AND status = 'active' `).bind( now, cleanText(request.headers.get('CF-Connecting-IP'), 60), cleanText(request.headers.get('User-Agent'), 200), now, row.credential_id ).run(); } return { auth_mode: 'installation', credential_id: row.credential_id, installation_id: row.installation_id, manager_id: row.manager_id, manager_name: row.manager_name, stage: row.stage, platform: row.platform, }; } async function authenticateIngestRequest(request, env) { const installation = await authenticateInstallation(request, env); if (installation) return installation; // Временный мост миграции: старый общий токен допускается только // для телеметрии. Эндпоинты решений его никогда не принимают. const provided = bearerToken(request); if ( env.INGEST_TOKEN && safeEqual(provided, String(env.INGEST_TOKEN)) ) { return { auth_mode: 'legacy' }; } return null; } function assertPayloadInstallation(payload, installationId) { const supplied = cleanText( payload?.installation_id || payload?.installationId, 160 ); if (supplied && !safeEqual(supplied, installationId)) { throw new PublicError('INSTALLATION_ID_MISMATCH', 403); } } function bindDecisionIdentity(payload, identity) { return { ...(payload || {}), installation_id: identity.installation_id, manager_id: identity.manager_id, manager_name: identity.manager_name, stage: identity.stage, platform: identity.platform, }; } function bindIngestIdentity(payload, identity) { if (identity.auth_mode === 'legacy') return { ...(payload || {}), __auth_mode: 'legacy' }; assertPayloadInstallation(payload, identity.installation_id); const trusted = { ...(payload || {}), installation_id: identity.installation_id, manager_id: identity.manager_id, manager_name: identity.manager_name, stage: identity.stage, platform: identity.platform, __auth_mode: 'installation', }; if (payload?.installation && typeof payload.installation === 'object') { trusted.installation = { ...payload.installation, installation_id: identity.installation_id, manager_id: identity.manager_id, manager_name: identity.manager_name, stage: identity.stage, platform: identity.platform, __auth_mode: 'installation', }; } return trusted; } function bearerToken(request) { const value = request.headers.get('Authorization') || ''; const match = value.match(/^Bearer\s+(.+)$/i); return match ? match[1].trim() : ''; } function parseCookies(header) { const result = {}; for (const part of String(header).split(';')) { const index = part.indexOf('='); if (index < 0) continue; const key = part.slice(0, index).trim(); const value = part.slice(index + 1).trim(); if (key) result[key] = value; } return result; } function safeEqual(a, b) { const left = String(a || ''); const right = String(b || ''); const length = Math.max(left.length, right.length); let diff = left.length ^ right.length; for (let index = 0; index < length; index += 1) { diff |= (left.charCodeAt(index) || 0) ^ (right.charCodeAt(index) || 0); } return diff === 0; } async function readJsonBody(request) { return readJsonBodyWithLimit(request, MAX_BODY_BYTES); } async function readJsonBodyWithLimit(request, maximumBytes) { const safeMaximum = Math.max( 1024, Math.floor(Number(maximumBytes || MAX_BODY_BYTES)) ); const declaredLength = Number(request.headers.get('Content-Length') || 0); if (declaredLength > safeMaximum) { throw new PublicError('BODY_TOO_LARGE', 413); } const text = await request.text(); if (new TextEncoder().encode(text).byteLength > safeMaximum) { throw new PublicError('BODY_TOO_LARGE', 413); } if (!text.trim()) { throw new PublicError('EMPTY_BODY', 400); } try { return JSON.parse(text); } catch (_) { throw new PublicError('INVALID_JSON', 400); } } function normalizeLastSender(value) { const normalized = cleanText(value, 20).toLowerCase(); if (['client', 'assistant', 'manager'].includes(normalized)) { return normalized; } return ''; } function cleanText(value, maxLength = 120) { return String(value ?? '') .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, '') .replace(/\s+/g, ' ') .trim() .slice(0, maxLength); } function cleanCode(value, maxLength = 100) { return cleanText(value, maxLength) .replace(/[^a-zA-Z0-9_.:-]/g, '_') .slice(0, maxLength); } function redactCredentialText(value) { return String(value ?? '') .replace(/\b(?:iat|act)\.[a-zA-Z0-9_-]{8,120}\.[a-zA-Z0-9_-]{20,200}\b/g, '[REDACTED_INSTALLATION_TOKEN]') .replace(/\bsk-ant-[a-zA-Z0-9_-]+/gi, '[REDACTED_API_KEY]') .replace(/\bgsk_[a-zA-Z0-9_-]+/g, '[REDACTED_AUDIO_KEY]') .replace(/Bearer\s+[a-zA-Z0-9._~+\/-]+=*/gi, 'Bearer [REDACTED]'); } function redactSensitive(value, maxLength = 700) { return cleanText(redactCredentialText(value), maxLength * 2) .replace(/sk-ant-[a-zA-Z0-9_-]+/gi, '[REDACTED_API_KEY]') .replace(/\b\d{6,12}:[a-zA-Z0-9_-]{20,}\b/g, '[REDACTED_BOT_TOKEN]') .replace(/Bearer\s+[a-zA-Z0-9._~+\/-]+=*/gi, 'Bearer [REDACTED]') .replace(/([?&](?:key|token|access_token)=)[^&\s]+/gi, '$1[REDACTED]') .replace(/\+?\d[\d\s()\-]{7,}\d/g, '[REDACTED_ID]') .slice(0, maxLength); } function safeDetailsJson(value) { if (value === undefined || value === null) return '{}'; try { const serialized = JSON.stringify(value); const redacted = redactSensitive(serialized, 4000) || '{}'; try { JSON.parse(redacted); return redacted; } catch (_) { // Если строка была обрезана посередине JSON, сохраняем валидный объект. return JSON.stringify({ truncated: true, preview: redactSensitive(serialized, 3500), }); } } catch (_) { return '{"serialization_error":true}'; } } async function readFormDataLimited(request) { const declaredLength = Number(request.headers.get('Content-Length') || 0); if (declaredLength > MAX_FORM_BYTES) { throw new PublicError('FORM_TOO_LARGE', 413); } const body = await request.arrayBuffer(); if (body.byteLength > MAX_FORM_BYTES) { throw new PublicError('FORM_TOO_LARGE', 413); } const copy = new Request(request.url, { method: request.method, headers: request.headers, body, }); return copy.formData(); } function isSameOriginRequest(request) { const origin = request.headers.get('Origin'); const fetchSite = String( request.headers.get('Sec-Fetch-Site') || '' ).toLowerCase(); // Sec-Fetch-Site выставляет сам браузер. Для формы, открытой на этом же // Worker, это надёжное подтверждение запроса с той же страницы. if (fetchSite === 'same-origin') return true; // Явно чужой сайт не должен отправлять административные формы. if (fetchSite === 'cross-site') return false; // Совместимость с браузерами и клиентами без Fetch Metadata. if (!origin) return true; try { const requestUrl = new URL(request.url); const originUrl = new URL(origin); return ( originUrl.protocol === requestUrl.protocol && originUrl.host === requestUrl.host ); } catch (_) { return false; } } function safeTimestamp(value) { const number = Number(value || 0); if (!Number.isFinite(number) || number <= 0) return 0; return Math.trunc(Math.min(number, 8640000000000000)); } // Часовой пояс не меняет Unix-время, но неверные часы компьютера могут // прислать дату из будущего. Не позволяем ей ломать статусы и сортировку. function safeObservedTimestamp(value, now = Date.now()) { const timestamp = safeTimestamp(value); if (!timestamp) return 0; return timestamp > now + MAX_CLOCK_AHEAD_MS ? now : timestamp; } function clampInt(value, minimum, maximum) { const parsed = Number.parseInt(String(value ?? ''), 10); if (!Number.isFinite(parsed)) return minimum; return Math.min(maximum, Math.max(minimum, parsed)); } function boolInt(value) { return value === true || value === 1 || value === '1' ? 1 : 0; } function numberOrZero(value) { const number = Number(value || 0); return Number.isFinite(number) ? number : 0; } function normalizePath(pathname) { if (!pathname || pathname === '/') return '/'; return pathname.replace(/\/+$/, '') || '/'; } function jsonResponse(data, status = 200, cors = false) { return new Response(JSON.stringify(data), { status, headers: { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store', ...(cors ? corsHeaders() : securityHeaders()), }, }); } function htmlResponse(html, status = 200) { return new Response(html, { status, headers: { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store', ...securityHeaders(true), }, }); } function corsHeaders() { return { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', 'Access-Control-Allow-Headers': 'Authorization, Content-Type, X-Audio-Filename, X-OKZ-Client', 'Access-Control-Max-Age': '86400', ...securityHeaders(), }; } function securityHeaders(html = false) { const headers = { 'X-Content-Type-Options': 'nosniff', 'X-Frame-Options': 'DENY', 'Referrer-Policy': 'no-referrer', 'Permissions-Policy': 'camera=(), microphone=(), geolocation=()', }; if (html) { headers['Content-Security-Policy'] = [ "default-src 'none'", "script-src 'unsafe-inline'", "style-src 'unsafe-inline'", "connect-src 'self'", "img-src 'self' data:", "form-action 'self'", "frame-ancestors 'none'", "base-uri 'none'", ].join('; '); } return headers; } // ================================================================ // ПОЛЬЗОВАТЕЛИ, СЕССИИ И РОЛИ // admin — абсолютно всё, включая ошибки, аудит и пользователей // head — все стейджи, менеджеры и клиенты, без подробных логов ошибок // lead — тимлидер: только назначенные стейджи, без подробных логов ошибок // Фильтрация выполняется в SQL, а не в интерфейсе. // ================================================================ const ROLE_VALUES = new Set(['admin', 'head', 'lead']); const SESSION_TTL_MS = 12 * 60 * 60 * 1000; const LOGIN_MAX_ATTEMPTS = 5; const LOGIN_LOCK_MS = 15 * 60 * 1000; // Cloudflare Workers Web Crypto допускает не более 100000 итераций PBKDF2. const PBKDF2_ITERATIONS = 100000; let authSchemaPromise = null; async function ensureAuthSchema(env) { if (!authSchemaPromise) { authSchemaPromise = initializeAuthSchema(env).catch((error) => { authSchemaPromise = null; throw error; }); } return authSchemaPromise; } async function initializeAuthSchema(env) { const statements = [ `CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, login TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, password_salt TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'lead', display_name TEXT NOT NULL DEFAULT '', is_active INTEGER NOT NULL DEFAULT 1, must_change_password INTEGER NOT NULL DEFAULT 1, failed_attempts INTEGER NOT NULL DEFAULT 0, locked_until INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL DEFAULT 0, last_login_at INTEGER NOT NULL DEFAULT 0 )`, `CREATE TABLE IF NOT EXISTS user_stages ( user_id INTEGER NOT NULL, stage TEXT NOT NULL, PRIMARY KEY (user_id, stage) )`, `CREATE TABLE IF NOT EXISTS user_sessions ( token_hash TEXT PRIMARY KEY, user_id INTEGER NOT NULL, created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL, ip TEXT NOT NULL DEFAULT '', user_agent TEXT NOT NULL DEFAULT '' )`, `CREATE TABLE IF NOT EXISTS installation_stops ( installation_id TEXT PRIMARY KEY, stopped INTEGER NOT NULL DEFAULT 0, updated_at INTEGER NOT NULL DEFAULT 0, updated_by TEXT NOT NULL DEFAULT '' )`, `CREATE TABLE IF NOT EXISTS app_settings ( key TEXT PRIMARY KEY, value TEXT NOT NULL DEFAULT '', updated_at INTEGER NOT NULL DEFAULT 0, updated_by TEXT NOT NULL DEFAULT '' )`, `CREATE TABLE IF NOT EXISTS audit_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, at INTEGER NOT NULL, user_id INTEGER NOT NULL DEFAULT 0, login TEXT NOT NULL DEFAULT '', action TEXT NOT NULL, target TEXT NOT NULL DEFAULT '', ip TEXT NOT NULL DEFAULT '' )`, `CREATE INDEX IF NOT EXISTS idx_sessions_expires ON user_sessions(expires_at)`, `CREATE INDEX IF NOT EXISTS idx_audit_at ON audit_log(at)`, ]; for (const sql of statements) { await env.DB.prepare(sql).run(); } // Первый вход: если пользователей нет, создаём админа из ADMIN_TOKEN. // Логин admin, пароль равен ADMIN_TOKEN, смена обязательна при входе. const count = await env.DB.prepare( 'SELECT COUNT(*) AS total FROM users' ).first(); if (!Number(count?.total || 0) && env.ADMIN_TOKEN) { const { hash, salt } = await hashPassword(String(env.ADMIN_TOKEN)); await env.DB.prepare(` INSERT INTO users ( login, password_hash, password_salt, role, display_name, is_active, must_change_password, created_at ) VALUES (?, ?, ?, 'admin', 'Администратор', 1, 1, ?) `).bind('admin', hash, salt, Date.now()).run(); } } // ---------- Пароли: PBKDF2-SHA256 (bcrypt в Workers недоступен) ---------- function bytesToHex(buffer) { return Array.from(new Uint8Array(buffer)) .map((byte) => byte.toString(16).padStart(2, '0')) .join(''); } async function hashPassword(password, saltHex = null) { const salt = saltHex ? Uint8Array.from( saltHex.match(/.{2}/g).map((byte) => parseInt(byte, 16)) ) : crypto.getRandomValues(new Uint8Array(16)); const key = await crypto.subtle.importKey( 'raw', new TextEncoder().encode(String(password)), 'PBKDF2', false, ['deriveBits'] ); const bits = await crypto.subtle.deriveBits( { name: 'PBKDF2', salt, iterations: PBKDF2_ITERATIONS, hash: 'SHA-256', }, key, 256 ); return { hash: bytesToHex(bits), salt: bytesToHex(salt.buffer || salt), }; } async function verifyPassword(password, storedHash, storedSalt) { const { hash } = await hashPassword(password, storedSalt); return safeEqual(hash, storedHash); } // ---------- Сессии ---------- async function sha256Hex(value) { const digest = await crypto.subtle.digest( 'SHA-256', new TextEncoder().encode(String(value)) ); return bytesToHex(digest); } async function createUserSession(env, user, request) { const token = crypto.randomUUID() + crypto.randomUUID().slice(0, 8); const tokenHash = await sha256Hex(token); const now = Date.now(); await env.DB.prepare(` INSERT INTO user_sessions ( token_hash, user_id, created_at, expires_at, ip, user_agent ) VALUES (?, ?, ?, ?, ?, ?) `).bind( tokenHash, user.id, now, now + SESSION_TTL_MS, cleanText(request.headers.get('CF-Connecting-IP'), 60), cleanText(request.headers.get('User-Agent'), 200) ).run(); return token; } async function getSessionUser(request, env) { const cookies = parseCookies(request.headers.get('Cookie') || ''); const token = cookies.monitor_session || ''; if (!token) return null; const tokenHash = await sha256Hex(token); const row = await env.DB.prepare(` SELECT u.id, u.login, u.role, u.display_name, u.is_active, u.must_change_password, s.expires_at FROM user_sessions s JOIN users u ON u.id = s.user_id WHERE s.token_hash = ? `).bind(tokenHash).first(); if (!row) return null; if (Number(row.expires_at || 0) < Date.now() || !Number(row.is_active)) { await env.DB.prepare( 'DELETE FROM user_sessions WHERE token_hash = ?' ).bind(tokenHash).run(); return null; } const stages = row.role === 'lead' ? (await env.DB.prepare( 'SELECT stage FROM user_stages WHERE user_id = ?' ).bind(row.id).all()).results?.map((item) => item.stage) || [] : []; return { id: row.id, login: row.login, role: row.role, displayName: row.display_name, mustChangePassword: Number(row.must_change_password) === 1, stages, tokenHash, }; } function sessionCookie(token) { return [ `monitor_session=${token}`, 'Path=/', 'HttpOnly', 'Secure', 'SameSite=Strict', `Max-Age=${Math.floor(SESSION_TTL_MS / 1000)}`, ].join('; '); } function clearSessionCookie() { return [ 'monitor_session=', 'Path=/', 'HttpOnly', 'Secure', 'SameSite=Strict', 'Max-Age=0', ].join('; '); } // ---------- Права ---------- // Возвращает кусок SQL и параметры для ограничения выборки по стейджам. // Для admin и head ограничения нет. Для lead без назначенных стейджей // подставляется заведомо ложное условие: пустой доступ вместо полного. function stageFilter(user, column = 'stage') { if (!user) return { sql: ' AND 1 = 0', params: [] }; if (user.role === 'admin' || user.role === 'head') { return { sql: '', params: [] }; } if (!user.stages.length) { return { sql: ' AND 1 = 0', params: [] }; } const placeholders = user.stages.map(() => '?').join(', '); return { sql: ` AND ${column} IN (${placeholders})`, params: [...user.stages], }; } function canSeeErrors(user) { return user?.role === 'admin'; } function canManageUsers(user) { return user?.role === 'admin'; } // ================================================================ // ГЛОБАЛЬНАЯ ОСТАНОВКА ВСЕХ СКРИПТОВ // Команда уходит скриптам в ответе heartbeat. Скрипт сохраняет её // локально, поэтому перезагрузка вкладки и потеря связи с сервером // остановленного помощника не «оживляют». // ================================================================ async function readGlobalStop(env) { try { const row = await env.DB.prepare( "SELECT value, updated_at, updated_by FROM app_settings WHERE key = 'global_stop'" ).first(); return { stopped: String(row?.value || '') === '1', at: numberOrZero(row?.updated_at), by: String(row?.updated_by || ''), }; } catch (_) { // Настройки недоступны — не останавливаем работу без явной команды return { stopped: false, at: 0, by: '' }; } } async function writeGlobalStop(env, stopped, user) { await env.DB.prepare(` INSERT INTO app_settings (key, value, updated_at, updated_by) VALUES ('global_stop', ?, ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at, updated_by = excluded.updated_by `).bind( stopped ? '1' : '0', Date.now(), cleanText(user?.login, 80) ).run(); } // ================================================================ // АКТУАЛЬНЫЕ ВЕРСИИ СКРИПТОВ // Администратор указывает версию, которую считает актуальной. // Она уходит скриптам в ответе heartbeat: отставший показывает // менеджеру плашку «доступно обновление», в админке такие установки // подсвечиваются. Работать при этом никто не мешает. // ================================================================ const VERSION_PLATFORMS = ['telegram', 'max', 'whatsapp', 'gemspace']; // Ежедневное напоминание о перезапуске браузера. // Время и текст задаёт администратор на сайте, чтобы менять их // без перезаливки скриптов всем менеджерам. const RESTART_DEFAULTS = { enabled: false, time: '18:50', text: 'Зайдите в Dolphin, нажмите «Стоп», затем снова запустите профиль.', }; async function readRestartReminder(env) { try { const rows = await env.DB.prepare( "SELECT key, value FROM app_settings WHERE key LIKE 'restart_%'" ).all(); const map = {}; for (const row of rows.results || []) { map[String(row.key || '')] = String(row.value || ''); } return { enabled: map.restart_enabled === '1', time: /^\d{2}:\d{2}$/.test(map.restart_time || '') ? map.restart_time : RESTART_DEFAULTS.time, text: map.restart_text || RESTART_DEFAULTS.text, }; } catch (_) { return { ...RESTART_DEFAULTS }; } } async function writeRestartReminder(env, settings, user) { const rows = [ ['restart_enabled', settings.enabled ? '1' : '0'], ['restart_time', /^\d{2}:\d{2}$/.test(settings.time || '') ? settings.time : RESTART_DEFAULTS.time], ['restart_text', cleanText(settings.text, 400) || RESTART_DEFAULTS.text], ]; for (const [key, value] of rows) { await env.DB.prepare(` INSERT INTO app_settings (key, value, updated_at, updated_by) VALUES (?, ?, ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at, updated_by = excluded.updated_by `).bind(key, value, Date.now(), cleanText(user?.login, 80)).run(); } } async function readLoaderUrls(env) { const result = {}; try { const rows = await env.DB.prepare( "SELECT key, value FROM app_settings WHERE key LIKE 'loader_url_%'" ).all(); for (const row of rows.results || []) { const platform = String(row.key || '').replace('loader_url_', ''); if (VERSION_PLATFORMS.includes(platform)) { result[platform] = String(row.value || ''); } } } catch (_) {} return result; } let latestVersionsCache = { at: 0, value: {}, }; async function readLatestVersions( env ) { const now = Date.now(); if ( now - Number( latestVersionsCache.at || 0 ) < 30 * 1000 ) { return { ...latestVersionsCache.value, }; } const result = {}; if ( env.CONFIG_KV && typeof env.CONFIG_KV.get === 'function' ) { try { const values = await Promise.all( VERSION_PLATFORMS.map( (platform) => env.CONFIG_KV.get( platform + '_version' ) ) ); VERSION_PLATFORMS.forEach( (platform, index) => { const version = String( values[index] || '' ).trim(); if ( /^\d+\.\d+\.\d+$/ .test(version) ) { result[platform] = version; } } ); } catch (_) {} } try { const rows = await env.DB.prepare( "SELECT key, value FROM app_settings WHERE key LIKE 'latest_version_%'" ).all(); for ( const row of rows.results || [] ) { const platform = String( row.key || '' ).replace( 'latest_version_', '' ); if ( VERSION_PLATFORMS .includes(platform) && !result[platform] ) { result[platform] = String( row.value || '' ).trim(); } } } catch (_) {} latestVersionsCache = { at: now, value: { ...result, }, }; return result; } async function writeLatestVersion(env, platform, version, user) { if (!VERSION_PLATFORMS.includes(platform)) return; const cleanVersion = cleanText(version, 20); if ( env.CONFIG_KV && typeof env.CONFIG_KV.put === 'function' && /^\d+\.\d+\.\d+$/.test(cleanVersion) ) { await env.CONFIG_KV.put( platform + '_version', cleanVersion ); latestVersionsCache = { at: 0, value: {}, }; } await env.DB.prepare(` INSERT INTO app_settings (key, value, updated_at, updated_by) VALUES (?, ?, ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at, updated_by = excluded.updated_by `).bind( 'latest_version_' + platform, cleanVersion, Date.now(), cleanText(user?.login, 80) ).run(); } // Индивидуальная остановка одной установки. Работает независимо // от глобального стоп-крана: помощник молчит, если включено любое из двух. async function readInstallationStop(env, installationId) { try { const row = await env.DB.prepare( 'SELECT stopped FROM installation_stops WHERE installation_id = ?' ).bind(String(installationId || '')).first(); return Number(row?.stopped || 0) === 1; } catch (_) { return false; } } async function writeInstallationStop(env, installationId, stopped, user) { await env.DB.prepare(` INSERT INTO installation_stops (installation_id, stopped, updated_at, updated_by) VALUES (?, ?, ?, ?) ON CONFLICT(installation_id) DO UPDATE SET stopped = excluded.stopped, updated_at = excluded.updated_at, updated_by = excluded.updated_by `).bind( String(installationId || ''), stopped ? 1 : 0, Date.now(), cleanText(user?.login, 80) ).run(); } async function writeAudit(env, user, action, target, request) { try { await env.DB.prepare(` INSERT INTO audit_log (at, user_id, login, action, target, ip) VALUES (?, ?, ?, ?, ?, ?) `).bind( Date.now(), Number(user?.id || 0), cleanText(user?.login, 80), cleanText(action, 80), cleanText(target, 200), cleanText(request?.headers?.get('CF-Connecting-IP'), 60) ).run(); } catch (_) { // Аудит не должен ломать основной запрос } } // ---------- Вход ---------- async function handleUserLogin(request, env) { await ensureAuthSchema(env); if (!isSameOriginRequest(request)) { return htmlResponse('Запрос отклонён', 403); } const form = await readFormDataLimited(request); const login = cleanText(form.get('login'), 80).toLowerCase(); const password = String(form.get('password') || ''); const now = Date.now(); await enforceRateLimit( env.LOGIN_RATE_LIMITER, `login:${request.headers.get('CF-Connecting-IP') || 'unknown'}:${login}`, 'LOGIN_RATE_LIMITED' ); const user = await env.DB.prepare(` SELECT * FROM users WHERE login = ? `).bind(login).first(); const fail = async (reason) => { await writeAudit(env, { id: 0, login }, 'login_failed', reason, request); return htmlResponse( loginPageHtml('Неверный логин или пароль'), 401 ); }; if (password.length > 256 || !user || !Number(user.is_active)) { // Небольшая задержка против перебора await new Promise((resolve) => setTimeout(resolve, 400)); return fail('unknown_or_disabled'); } if (Number(user.locked_until || 0) > now) { const minutes = Math.ceil( (Number(user.locked_until) - now) / 60000 ); return htmlResponse( loginPageHtml( `Учётная запись временно заблокирована. Повторите через ${minutes} мин.` ), 429 ); } const valid = await verifyPassword( password, user.password_hash, user.password_salt ); if (!valid) { const attempts = Number(user.failed_attempts || 0) + 1; const lockedUntil = attempts >= LOGIN_MAX_ATTEMPTS ? now + LOGIN_LOCK_MS : 0; await env.DB.prepare(` UPDATE users SET failed_attempts = ?, locked_until = ? WHERE id = ? `).bind( lockedUntil ? 0 : attempts, lockedUntil, user.id ).run(); await new Promise((resolve) => setTimeout(resolve, 400)); return fail('bad_password'); } await env.DB.prepare(` UPDATE users SET failed_attempts = 0, locked_until = 0, last_login_at = ? WHERE id = ? `).bind(now, user.id).run(); const token = await createUserSession(env, user, request); await writeAudit(env, user, 'login', user.role, request); return new Response(null, { status: 303, headers: { Location: Number(user.must_change_password) === 1 ? '/admin/password' : '/admin', 'Set-Cookie': sessionCookie(token), ...securityHeaders(), }, }); } async function handleUserLogout(request, env) { if (!isSameOriginRequest(request)) { return htmlResponse('Запрос отклонён', 403); } const user = await getSessionUser(request, env); if (user) { await env.DB.prepare( 'DELETE FROM user_sessions WHERE token_hash = ?' ).bind(user.tokenHash).run(); await writeAudit(env, user, 'logout', '', request); } return new Response(null, { status: 303, headers: { Location: '/admin', 'Set-Cookie': clearSessionCookie(), ...securityHeaders(), }, }); } async function handlePasswordChange(request, env) { if (!isSameOriginRequest(request)) { return htmlResponse('Запрос отклонён', 403); } const user = await getSessionUser(request, env); if (!user) { return new Response(null, { status: 303, headers: { Location: '/admin', ...securityHeaders() }, }); } const form = await readFormDataLimited(request); const current = String(form.get('current') || ''); const next = String(form.get('next') || ''); const repeat = String(form.get('repeat') || ''); const row = await env.DB.prepare( 'SELECT password_hash, password_salt FROM users WHERE id = ?' ).bind(user.id).first(); if (!(await verifyPassword(current, row.password_hash, row.password_salt))) { return htmlResponse( passwordPageHtml(user, 'Текущий пароль указан неверно'), 401 ); } if (next.length < 10) { return htmlResponse( passwordPageHtml(user, 'Новый пароль должен быть не короче 10 символов'), 400 ); } if (next.length > 256) { return htmlResponse( passwordPageHtml(user, 'Новый пароль не должен превышать 256 символов'), 400 ); } if (next !== repeat) { return htmlResponse( passwordPageHtml(user, 'Новый пароль и подтверждение не совпадают'), 400 ); } const { hash, salt } = await hashPassword(next); await env.DB.prepare(` UPDATE users SET password_hash = ?, password_salt = ?, must_change_password = 0 WHERE id = ? `).bind(hash, salt, user.id).run(); // Все прочие сессии этого пользователя завершаем await env.DB.prepare(` DELETE FROM user_sessions WHERE user_id = ? AND token_hash <> ? `).bind(user.id, user.tokenHash).run(); await writeAudit(env, user, 'password_changed', '', request); return new Response(null, { status: 303, headers: { Location: '/admin', ...securityHeaders() }, }); } // ================================================================ // УПРАВЛЕНИЕ ПОЛЬЗОВАТЕЛЯМИ (только admin) // ================================================================ function randomPassword() { const alphabet = 'abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789'; const bytes = crypto.getRandomValues(new Uint8Array(14)); return Array.from(bytes) .map((byte) => alphabet[byte % alphabet.length]) .join(''); } // Страница загрузчиков: воркер сам собирает готовый текст для Tampermonkey. // Версия берётся из настроек, адрес и ключ конфиг-воркера — из app_settings // (значения по умолчанию заданы ниже, менять их можно на этой же странице). const LOADER_DEFAULT_BASE = 'https://ai-asstistant-config.mikhailkorolev555.workers.dev'; const LOADER_PLATFORMS = [ { key: 'telegram', title: 'Telegram', name: 'AI Помощник для Telegram Web K', match: 'https://web.telegram.org/k/*', site: 'web.telegram.org/k/', extraConnect: [], }, { key: 'max', title: 'MAX', name: 'AI Помощник для MAX (web.max.ru)', match: 'https://web.max.ru/*', site: 'web.max.ru', extraConnect: ['i.oneme.ru', 'raw.githubusercontent.com', 'openrouter.ai'], }, { key: 'whatsapp', title: 'WhatsApp', name: 'AI Помощник для WhatsApp Web', match: 'https://web.whatsapp.com/*', site: 'web.whatsapp.com', extraConnect: ['raw.githubusercontent.com'], }, { key: 'gemspace', title: 'Gem Space', name: 'AI Помощник для Gem Space', match: 'https://web.gemspace.com/*', site: 'web.gemspace.com', extraConnect: [ 'storage.googleapis.com', 'firebasestorage.googleapis.com', 'lh3.googleusercontent.com', ], }, ]; function buildLoaderSource(platform, version, base, key, monitorHost) { const connects = [ monitorHost, 'cdn.aiclientspr.xyz', 'api.anthropic.com', 'api.telegram.org', 'api.groq.com', 'gist.githubusercontent.com', base.replace(/^https?:\/\//, '').replace(/\/$/, ''), ].concat(platform.extraConnect); const unique = []; for (const host of connects) { if (host && unique.indexOf(host) === -1) unique.push(host); } const loaderUrl = base + '/' + platform.key + '-loader.user.js?key=' + key; const requireUrl = base + '/' + platform.key + '.require.js?key=' + key + '&v=' + version; return [ '// ==UserScript==', '// @name ' + platform.name, '// @namespace local', '// @version ' + version, '// @description Облачный загрузчик помощника', '// @match ' + platform.match, '// @run-at document-idle', '// @noframes', '// @grant GM_xmlhttpRequest', '// @grant GM_setValue', '// @grant GM_getValue', '// @grant GM_notification', '// @grant GM_registerMenuCommand', '// @grant GM_listValues', '// @grant GM_deleteValue', '// @grant GM_info', '// @grant unsafeWindow', ].concat( unique.map((host) => '// @connect ' + host) ).concat([ '// @updateURL ' + loaderUrl, '// @downloadURL ' + loaderUrl, '// @require ' + requireUrl, '// ==/UserScript==', '// Полный код загружается директивой @require.', '// eval и new Function здесь намеренно не используются.', '', ]).join('\n'); } async function handleLoadersPage(request, env, user) { const versions = await readLatestVersions(env); const monitorHost = new URL(request.url).host; let base = LOADER_DEFAULT_BASE; let key = ''; try { const rows = await env.DB.prepare( "SELECT key, value FROM app_settings WHERE key IN ('config_base_url', 'config_key')" ).all(); for (const row of rows.results || []) { if (row.key === 'config_base_url' && String(row.value || '').trim()) { base = String(row.value).trim().replace(/\/$/, ''); } if (row.key === 'config_key') { key = String(row.value || '').trim(); } } } catch (_) {} const columns = LOADER_PLATFORMS.map((platform) => { const version = versions[platform.key] || ''; if (!version) { return '
' + '
' + '
' + escapeHtml(platform.title) + '
' + '
' + escapeHtml(platform.site) + '
' + '
' + '
Версия не задана. Впишите её на главной странице ' + 'в поле «Актуальные версии».
' + '
'; } const code = buildLoaderSource(platform, version, base, key || 'ВСТАВЬТЕ_КЛЮЧ', monitorHost); return '
' + '
' + '
' + '
' + escapeHtml(platform.title) + '
' + '
' + escapeHtml(platform.site) + ' · ' + escapeHtml(version) + '
' + '
' + '' + '
' + (key ? '' : '
Ключ конфиг-воркера не задан — вставьте его ниже.
') + '' + '
'; }); const page = '' + '' + 'Загрузчики
' + '

Загрузчики

' + '

← к мониторингу · ' + 'версии подставляются автоматически

' + '
' + 'Конфиг-воркер:' + '' + '' + '' + 'задаётся один раз' + '
' + '
' + columns.join('') + '
' + '
'; return htmlResponse(page, 200); } async function handleUsersPage(request, env, user, notice = '') { const users = (await env.DB.prepare(` SELECT id, login, role, display_name, is_active, must_change_password, last_login_at, locked_until FROM users ORDER BY role, login `).all()).results || []; const stageRows = (await env.DB.prepare( 'SELECT user_id, stage FROM user_stages ORDER BY stage' ).all()).results || []; const stagesByUser = {}; for (const row of stageRows) { if (!stagesByUser[row.user_id]) stagesByUser[row.user_id] = []; stagesByUser[row.user_id].push(row.stage); } const knownStages = (await env.DB.prepare(` SELECT stage FROM ( SELECT canonical_stage AS stage FROM manager_stages WHERE active = 1 UNION SELECT TRIM(stage) AS stage FROM installations WHERE TRIM(COALESCE(stage, '')) <> '' ) ORDER BY stage COLLATE NOCASE `).all()).results?.map((row) => row.stage) || []; return htmlResponse( usersPageHtml(user, users, stagesByUser, knownStages, notice), 200 ); } async function handleUserCreate(request, env, actor) { if (!isSameOriginRequest(request)) { return htmlResponse('Запрос отклонён', 403); } const form = await readFormDataLimited(request); const login = cleanText(form.get('login'), 80).toLowerCase().trim(); const role = cleanText(form.get('role'), 20); const displayName = cleanText(form.get('display_name'), 120); const stages = cleanText(form.get('stages'), 500) .split(',') .map((item) => item.trim()) .filter(Boolean); if (!login || !/^[a-z0-9._-]{3,40}$/.test(login)) { return handleUsersPage( request, env, actor, 'Логин: латиница, цифры, точка, дефис, подчёркивание, 3–40 символов' ); } if (!ROLE_VALUES.has(role)) { return handleUsersPage(request, env, actor, 'Некорректная роль'); } const exists = await env.DB.prepare( 'SELECT id FROM users WHERE login = ?' ).bind(login).first(); if (exists) { return handleUsersPage(request, env, actor, 'Такой логин уже занят'); } const password = randomPassword(); const { hash, salt } = await hashPassword(password); const result = await env.DB.prepare(` INSERT INTO users ( login, password_hash, password_salt, role, display_name, is_active, must_change_password, created_at ) VALUES (?, ?, ?, ?, ?, 1, 1, ?) `).bind(login, hash, salt, role, displayName, Date.now()).run(); const userId = result.meta?.last_row_id; if (role === 'lead' && stages.length && userId) { for (const stage of stages) { await env.DB.prepare( 'INSERT OR IGNORE INTO user_stages (user_id, stage) VALUES (?, ?)' ).bind(userId, stage).run(); } } await writeAudit(env, actor, 'user_created', `${login} (${role})`, request); return handleUsersPage( request, env, actor, `Создан пользователь ${login}. Временный пароль: ${password} — передайте лично, при первом входе система потребует его сменить.` ); } async function handleUserUpdate(request, env, actor) { if (!isSameOriginRequest(request)) { return htmlResponse('Запрос отклонён', 403); } const form = await readFormDataLimited(request); const userId = clampInt(form.get('user_id'), 1, 1e9); const action = cleanText(form.get('action'), 40); const target = await env.DB.prepare( 'SELECT id, login, role FROM users WHERE id = ?' ).bind(userId).first(); if (!target) { return handleUsersPage(request, env, actor, 'Пользователь не найден'); } if (action === 'reset_password') { const password = randomPassword(); const { hash, salt } = await hashPassword(password); await env.DB.prepare(` UPDATE users SET password_hash = ?, password_salt = ?, must_change_password = 1, failed_attempts = 0, locked_until = 0 WHERE id = ? `).bind(hash, salt, userId).run(); await env.DB.prepare( 'DELETE FROM user_sessions WHERE user_id = ?' ).bind(userId).run(); await writeAudit(env, actor, 'password_reset', target.login, request); return handleUsersPage( request, env, actor, `Новый временный пароль для ${target.login}: ${password}` ); } if (action === 'toggle_active') { if (target.id === actor.id) { return handleUsersPage( request, env, actor, 'Нельзя отключить собственную учётную запись' ); } await env.DB.prepare(` UPDATE users SET is_active = CASE is_active WHEN 1 THEN 0 ELSE 1 END WHERE id = ? `).bind(userId).run(); await env.DB.prepare( 'DELETE FROM user_sessions WHERE user_id = ?' ).bind(userId).run(); await writeAudit(env, actor, 'user_toggled', target.login, request); return handleUsersPage(request, env, actor, `Статус ${target.login} изменён`); } if (action === 'set_stages') { const stages = cleanText(form.get('stages'), 500) .split(',') .map((item) => item.trim()) .filter(Boolean); await env.DB.prepare( 'DELETE FROM user_stages WHERE user_id = ?' ).bind(userId).run(); for (const stage of stages) { await env.DB.prepare( 'INSERT OR IGNORE INTO user_stages (user_id, stage) VALUES (?, ?)' ).bind(userId, stage).run(); } await writeAudit( env, actor, 'stages_updated', `${target.login}: ${stages.join(', ') || 'нет'}`, request ); return handleUsersPage( request, env, actor, `Стейджи ${target.login}: ${stages.join(', ') || 'не назначены'}` ); } return handleUsersPage(request, env, actor, 'Неизвестное действие'); } // ---------- HTML ---------- const PAGE_STYLE = ` :root { color-scheme: dark; font-family: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; --bg: #080d18; --surface: #111a2a; --line: rgba(164, 191, 235, .15); --text: #edf3ff; --muted: #a0b2cd; --accent: #75a7ff; } * { box-sizing: border-box; } html { scroll-behavior: smooth; } body { margin: 0; min-height: 100vh; background: radial-gradient(circle at 8% 0%, rgba(69, 105, 209, .18), transparent 32rem), radial-gradient(circle at 100% 15%, rgba(17, 168, 173, .12), transparent 34rem), var(--bg); color: var(--text); line-height: 1.5; } ::selection { color: #fff; background: #3464af; } a { color: #9ac0ff; text-decoration: none; } a:hover { color: #c4d9ff; text-decoration: underline; } .wrap { max-width: 1180px; margin: 0 auto; padding: 42px 22px 80px; } h1 { font-size: clamp(24px, 3vw, 32px); letter-spacing: -.035em; margin: 0 0 7px; } h2 { font-size: 18px; letter-spacing: -.02em; margin: 30px 0 14px; } .muted { color: var(--muted); font-size: 13px; } .notice, .error { margin: 18px 0; padding: 13px 16px; border-radius: 13px; line-height: 1.55; overflow-wrap: anywhere; } .notice { color: #c8e4ff; background: rgba(54, 116, 196, .14); border: 1px solid rgba(104, 166, 242, .28); } .error { color: #ffc6cf; background: rgba(225, 67, 98, .12); border: 1px solid rgba(255, 115, 143, .3); } .card { padding: 22px; border: 1px solid var(--line); border-radius: 19px; background: linear-gradient(155deg, rgba(26, 39, 61, .96), rgba(14, 23, 39, .98)); box-shadow: 0 18px 44px rgba(0, 3, 15, .24), inset 0 1px rgba(255,255,255,.035); } .grid { display: grid; gap: 12px; grid-template-columns: repeat(auto-fit, minmax(min(100%, 200px), 1fr)); } table { width: 100%; border-collapse: separate; border-spacing: 0; font-size: 13px; } th, td { padding: 12px 13px; border-bottom: 1px solid rgba(165, 190, 225, .1); text-align: left; vertical-align: middle; } th { color: #aebfda; font-weight: 650; background: rgba(88, 127, 185, .08); white-space: nowrap; } tbody tr:last-child td { border-bottom: 0; } tbody tr:hover td { background: rgba(110, 149, 220, .055); } input, select, textarea { max-width: 100%; padding: 10px 12px; border: 1px solid #344762; border-radius: 10px; background: #0b1525; color: #f5f8ff; outline: none; font: inherit; transition: border-color .18s ease, box-shadow .18s ease, background-color .18s ease; } input::placeholder, textarea::placeholder { color: #8295b1; } input:focus, select:focus, textarea:focus { border-color: #80adff; box-shadow: 0 0 0 3px rgba(91, 149, 255, .17); } button { padding: 10px 14px; border: 1px solid rgba(122, 173, 255, .52); border-radius: 10px; background: linear-gradient(135deg, #397ce7, #2b63cf); color: #fff; font: inherit; font-size: 13px; font-weight: 700; cursor: pointer; box-shadow: 0 5px 16px rgba(34, 94, 204, .18); transition: transform .18s ease, box-shadow .18s ease, background-color .18s ease, border-color .18s ease; } button:hover:not(:disabled) { transform: translateY(-1px); box-shadow: 0 8px 20px rgba(34, 94, 204, .28); } button:active:not(:disabled) { transform: translateY(0); } button:disabled { opacity: .55; cursor: not-allowed; } button.ghost { background: #23354e; border-color: #3a5473; box-shadow: none; } form.inline { display: inline; } .badge { display: inline-block; padding: 4px 9px; border-radius: 999px; font-size: 11px; font-weight: 750; white-space: nowrap; } .badge.green { background: rgba(31, 159, 111, .18); color: #75e5b8; } .badge.red { background: rgba(235, 74, 104, .18); color: #ffadbe; } tr.overdue-warn > td { background: rgba(240, 184, 61, .1); } tr.overdue-warn > td:first-child { box-shadow: inset 3px 0 0 #f0b83d; } tr.overdue-alert > td { background: rgba(239, 88, 111, .13); } tr.overdue-alert > td:first-child { box-shadow: inset 3px 0 0 #f0647c; } :is(a, button, input, select, textarea, summary):focus-visible { outline: 2px solid #a5c7ff; outline-offset: 3px; } @media (max-width: 700px) { .wrap { padding: 24px 14px 56px; } .card { padding: 16px; border-radius: 16px; } th, td { padding: 10px; } } @media (prefers-reduced-motion: reduce) { html { scroll-behavior: auto; } *, *::before, *::after { transition: none !important; animation: none !important; } } body { position: relative; isolation: isolate; overflow-x: hidden; } body::before, body::after { content: ''; position: fixed; pointer-events: none; z-index: 0; } body::before { inset: -18%; background: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22960%22%20height%3D%22540%22%20viewBox%3D%220%200%20960%20540%22%3E%0A%20%20%3Cg%20fill%3D%22none%22%20stroke%3D%22%2378b8ff%22%20stroke-opacity%3D%22.34%22%20stroke-width%3D%221%22%3E%0A%20%20%20%20%3Cpath%20d%3D%22M-25%20393%20C%2058%20400%2090%20298%20174%20303%20S%20304%20368%20366%20270%20S%20490%20176%20562%20218%20S%20724%20294%20801%20189%20S%20909%20115%20993%20135%22%2F%3E%0A%20%20%20%20%3Cpath%20d%3D%22M-24%20425%20C%2079%20455%20130%20399%20210%20366%20S%20344%20373%20411%20309%20S%20559%20295%20612%20338%20S%20799%20382%20857%20290%20S%20916%20226%20992%20241%22%2F%3E%0A%20%20%20%20%3Cpath%20d%3D%22M-25%20318%20C%2055%20298%20115%20213%20185%20252%20S%20317%20291%20365%20220%20S%20500%20108%20552%20158%20S%20683%20230%20750%20151%20S%20881%2058%20992%2089%22%2F%3E%0A%20%20%20%20%3Cpath%20d%3D%22M174%20303%20L185%20252%20M185%20252%20L210%20366%20M366%20270%20L365%20220%20M366%20270%20L411%20309%20M562%20218%20L552%20158%20M562%20218%20L612%20338%20M801%20189%20L750%20151%20M801%20189%20L857%20290%22%20stroke-opacity%3D%22.22%22%2F%3E%0A%20%20%3C%2Fg%3E%0A%20%20%3Cg%20fill%3D%22%2398d9ff%22%20fill-opacity%3D%22.75%22%3E%0A%20%20%20%20%3Ccircle%20cx%3D%22174%22%20cy%3D%22303%22%20r%3D%223%22%2F%3E%3Ccircle%20cx%3D%22185%22%20cy%3D%22252%22%20r%3D%222.3%22%2F%3E%3Ccircle%20cx%3D%22210%22%20cy%3D%22366%22%20r%3D%222.1%22%2F%3E%0A%20%20%20%20%3Ccircle%20cx%3D%22366%22%20cy%3D%22270%22%20r%3D%223.4%22%2F%3E%3Ccircle%20cx%3D%22365%22%20cy%3D%22220%22%20r%3D%222.1%22%2F%3E%3Ccircle%20cx%3D%22411%22%20cy%3D%22309%22%20r%3D%222.5%22%2F%3E%0A%20%20%20%20%3Ccircle%20cx%3D%22562%22%20cy%3D%22218%22%20r%3D%223%22%2F%3E%3Ccircle%20cx%3D%22552%22%20cy%3D%22158%22%20r%3D%222.3%22%2F%3E%3Ccircle%20cx%3D%22612%22%20cy%3D%22338%22%20r%3D%222.2%22%2F%3E%0A%20%20%20%20%3Ccircle%20cx%3D%22801%22%20cy%3D%22189%22%20r%3D%223.2%22%2F%3E%3Ccircle%20cx%3D%22750%22%20cy%3D%22151%22%20r%3D%222.2%22%2F%3E%3Ccircle%20cx%3D%22857%22%20cy%3D%22290%22%20r%3D%222.5%22%2F%3E%0A%20%20%3C%2Fg%3E%0A%3C%2Fsvg%3E") center / 960px 540px repeat; opacity: .24; animation: neural-drift 48s ease-in-out infinite alternate; } body::after { inset: -20%; background: radial-gradient(ellipse 34% 20% at 25% 68%, rgba(62,126,234,.18), transparent 80%), radial-gradient(ellipse 30% 18% at 78% 28%, rgba(31,194,189,.14), transparent 80%); animation: ambient-wave 34s ease-in-out infinite alternate; } .wrap, body > main { position: relative; z-index: 1; } .wrap { max-width: 1240px; } .wrap > h1 { display: flex; align-items: center; gap: 11px; } .wrap > h1::before { content: '✦'; display: inline-grid; place-items: center; width: 36px; height: 36px; flex: 0 0 36px; border: 1px solid rgba(126,178,255,.48); border-radius: 11px; background: linear-gradient(135deg,#3172c7,#433b9a); color: #fff; font-size: 18px; box-shadow: 0 9px 21px rgba(60,88,196,.24); } .wrap > p.muted { margin: 8px 0 26px; line-height: 1.6; } .wrap > p.muted a { display: inline-block; padding: 5px 9px; border: 1px solid rgba(137,180,241,.18); border-radius: 8px; background: rgba(35,56,86,.45); } .wrap > h2 { display: flex; align-items: center; gap: 9px; margin-top: 34px; } .wrap > h2::before { content: ''; width: 4px; height: 17px; border-radius: 9px; background: linear-gradient(#89b6ff,#66dccb); } .card { transition: border-color .22s ease, box-shadow .22s ease, transform .22s ease; } .card:hover { border-color: rgba(160,199,255,.26); box-shadow: 0 21px 49px rgba(0,3,15,.27), inset 0 1px rgba(255,255,255,.04); } .card:focus-within { border-color: rgba(141,190,255,.38); } body > main.card { width: min(460px, calc(100% - 32px)); padding: clamp(23px,4vw,34px); } .auth-brand { display: grid; place-items: center; width: 48px; height: 48px; margin-bottom: 18px; border-radius: 14px; color: #fff; font-size: 25px; background: linear-gradient(135deg,#78b6ff,#496bdb,#5146aa); box-shadow: 0 12px 27px rgba(64,103,219,.28); } .auth-eyebrow { margin: 0 0 8px; color: #83e1d0; font-size: 10px; font-weight: 800; letter-spacing: .16em; text-transform: uppercase; } body > main.card h1 { margin-bottom: 7px; } body > main.card p.muted { line-height: 1.55; } body > main.card label { font-size: 12px; font-weight: 700; } body > main.card input { min-height: 43px; } body > main.card button { min-height: 44px; } input[type="checkbox"] { accent-color: #78b0ff; } .wrap .card[style*="overflow:auto"] { overflow: auto; scrollbar-color: #4c6585 transparent; } .wrap .card td form.inline { display: inline-flex; margin: 2px 4px 2px 0; } .wrap table th { position: sticky; top: 0; z-index: 1; background: #1b2b43; } .wrap .grid > * { min-width: 0; } @keyframes neural-drift { from { transform: translate3d(-2%, 1%, 0); } to { transform: translate3d(4%, -3%, 0); } } @keyframes ambient-wave { from { transform: translate3d(-4%, 0, 0) scale(1); } to { transform: translate3d(5%, 3%, 0) scale(1.06); } } @media (max-width: 700px) { body::before { opacity: .15; } .wrap > h1 { font-size: 24px; } } @media (prefers-reduced-motion: reduce) { body::before, body::after { animation: none !important; } } `; function loginPageHtml(error = '') { return ` Вход — Панель ИИ
Operations / secure access

Панель ИИ

Вход для менеджмента

${error ? `
${escapeHtml(error)}
` : ''}
`; } function passwordPageHtml(user, error = '') { return ` Смена пароля
Operations / account

Смена пароля

${user.mustChangePassword ? 'Временный пароль нужно заменить на собственный.' : 'Задайте новый пароль.'} Минимум 10 символов.

${error ? `
${escapeHtml(error)}
` : ''}
`; } function usersPageHtml(actor, users, stagesByUser, knownStages, notice) { const roleName = { admin: 'Администратор', head: 'Хед', lead: 'Тимлидер', }; const rows = users.map((row) => { const stages = (stagesByUser[row.id] || []).join(', '); const active = Number(row.is_active) === 1; return ` ${escapeHtml(row.login)} ${escapeHtml(row.display_name || '—')} ${escapeHtml(roleName[row.role] || row.role)} ${row.role === 'lead' ? `
` : 'все'} ${active ? 'активен' : 'отключён'} ${row.last_login_at ? new Date(row.last_login_at).toLocaleString('ru-RU', { timeZone: 'Europe/Moscow' }) : 'ни разу'}
`; }).join(''); return ` Пользователи — Панель ИИ

Пользователи

← к мониторингу · вы вошли как ${escapeHtml(actor.login)} · журнал входов

${notice ? `
${escapeHtml(notice)}
` : ''}

Список

${rows || ''}
ЛогинИмяРольСтейджи СтатусПоследний вход
Пусто

Новый пользователь

Пароль сгенерируется автоматически и покажется один раз. ${knownStages.length ? `Стейджи из данных: ${escapeHtml(knownStages.join(', '))}` : 'Стейджи появятся, когда установки начнут присылать данные.'}

`; } async function handleAuditPage(request, env, actor) { const rows = (await env.DB.prepare(` SELECT at, login, action, target, ip FROM audit_log ORDER BY at DESC LIMIT 300 `).all()).results || []; const list = rows.map((row) => ` ${new Date(row.at).toLocaleString('ru-RU', { timeZone: 'Europe/Moscow' })} ${escapeHtml(row.login || '—')} ${escapeHtml(row.action)} ${escapeHtml(row.target || '—')} ${escapeHtml(row.ip || '—')} `).join(''); return htmlResponse(` Журнал доступа

Журнал доступа

← к мониторингу · пользователи

${list || ''}
Время, МСКЛогинДействиеДеталиIP
Пусто
`, 200); } async function handleInstallationSecurityPage(request, env, actor) { if (request.method !== 'GET') { return htmlResponse('Метод не поддерживается', 405); } await writeAudit(env, actor, 'installation_security_opened', '', request); return htmlResponse(` Безопасность установок

Установки и персональные токены

← к мониторингу · код активации и новый токен показываются только один раз

Проверяю серверные защиты…

Новая установка

Сервер сам создаст внутренний ID и закрепит стейдж с выбранным мессенджером. Менеджер введёт только одноразовый код, а имя, возраст, город и пол персонажа заполнит у себя в мессенджере.

Объединить ошибочный стейдж

Сервер перенесёт историю и доступы на правильный стейдж. На каждой платформе останется только самая свежая установка, остальные токены будут отозваны.

Зарегистрированные установки

СтейджПлатформаСтатусПоследнее использование, МСКInstallation ID
Загрузка…

Последние коды активации

Создан, МСКСтейджПлатформаСтатусИстекает, МСК
`, 200); } function escapeHtml(value) { return String(value ?? '') .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); } // Дашборд собирается под роль: подробные ошибки доступны только admin. // Хед видит все стейджи, тимлидер — только назначенные ему. function dashboardHtml(user) { const showErrors = canSeeErrors(user); const showAdminTools = user.role === 'admin'; const roleName = { admin: 'администратор', head: 'хед', lead: 'тимлидер', }[user.role] || user.role; const scopeNote = user.role === 'lead' ? (user.stages.length ? 'стейджи: ' + escapeHtml(user.stages.join(', ')) : 'стейджи не назначены — данных не будет') : 'все стейджи'; return ` AI Assistant Monitor
Operations / live

AI Assistant Monitor

Загрузка…
${escapeHtml(user.displayName || user.login)} · ${roleName} · ${scopeNote}
${canManageUsers(user) ? '' : ''} ${showAdminTools ? ` ` : ''} ${canManageUsers(user) ? '' : ''}
Центр управления

Рабочая панель

Состояние менеджеров, клиентов и решений в одном месте

Живые данные

Требуют решения

Решение менеджера текстом; время звонка укажите в инструкции ИИ
${showAdminTools ? ` ` : ''}

Менеджеры и установки

Подключения, версии и активность по платформам
${canManageUsers(user) ? `
Версии из скриптов: Версии берутся автоматически из @version скриптов в KV
Появится у всех менеджеров одновременно на весь экран
` : ''}

Клиенты по стейджам

Откройте стейдж, чтобы увидеть диалоги и ожидание ответа
${showErrors ? `

Ошибки за 24 часа

События для диагностики установок и ответов
Получено, МСКСтейдж и менеджерПлатформаКлиентЭтапКодСообщениеTrace ID
` : ''}
`; }