/**
 * src/web-chat/routes.ts
 *
 * POST /api/chat — talk to Muassis from the dashboard.
 *
 * Phase 1 device mode unlocks "I want to use Muassis without setting up
 * WhatsApp yet": after CLI onboarding the dashboard opens, the user types,
 * and Muassis responds. Same agent loop as WhatsApp — just a different
 * channel feeding it.
 *
 * Customer model: web-chat customers are stored in the same `customers`
 * table as WhatsApp customers, but with phone = `web-<uuid>` so they don't
 * clash with real numbers, and `metadata.channel = 'web'`. The dashboard
 * stores customer_id in localStorage for session continuity across reloads.
 */
import { randomUUID } from 'node:crypto';
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { join } from 'node:path';
import { z } from 'zod';
import { createChildLogger } from '../utils/logger.js';
import { supabase } from '../utils/supabase.js';
import { processMessage } from '../agent/brain.js';
import { authMiddleware } from '../auth/middleware.js';
import { learnFromConversation } from '../knowledge/unified.js';
import { getDataDir } from '../utils/data-dir.js';
import { extractGitHubUrl, learnFromGitHubUrl } from './learn-from-url.js';
import { detectProvider } from './email-providers.js';
import { tryQuickAction } from './quick-actions.js';
const log = createChildLogger('web-chat:routes');
const chatSchema = z.object({
    message: z.string().min(1).max(4000),
    customer_id: z.string().uuid().optional(),
    customer_name: z.string().min(1).max(80).optional(),
});
/**
 * Resolve the device's tenant — in Phase 1 there's exactly one (single-tenant
 * per device). Pick the most recent active/trial tenant owned by the auth user.
 */
async function resolveTenantForUser(userId) {
    const r = await supabase
        .from('tenants')
        .select('id, agent_name, status')
        .eq('owner_id', userId)
        .in('status', ['active', 'trial'])
        .order('created_at', { ascending: false })
        .limit(1)
        .maybeSingle();
    return r.data ?? null;
}
/**
 * Find an existing web customer or create a fresh one. We key off customer_id
 * if the client passes one (returning user); otherwise we mint a new customer
 * row whose phone is `web-<uuid>` so it sorts cleanly out of any WhatsApp set.
 */
async function ensureWebCustomer(tenantId, customerId, customerName) {
    if (customerId) {
        const r = await supabase
            .from('customers')
            .select('id, name, phone, metadata')
            .eq('id', customerId)
            .eq('tenant_id', tenantId)
            .maybeSingle();
        if (r.data)
            return r.data;
        // fall through and create — id no longer exists
    }
    const id = randomUUID();
    const phone = `web-${id.slice(0, 12)}`;
    const now = new Date().toISOString();
    const metadata = { channel: 'web', created_via: 'dashboard' };
    await supabase.from('customers').insert({
        id,
        tenant_id: tenantId,
        phone,
        name: customerName ?? null,
        language: 'ar',
        first_contact: now,
        last_contact: now,
        total_messages: 0,
        status: 'active',
        metadata,
    });
    log.info({ tenantId, customerId: id }, 'New web-chat customer created');
    return { id, name: customerName ?? null, phone, metadata };
}
/**
 * Patterns the user may paste in chat to configure the gateway directly,
 * without the LLM in the loop. Each one returns a finished assistant
 * reply so the chat handler can short-circuit. Returns null to continue
 * to the regular agent flow.
 *
 * Recognized today:
 *   - KEY=VALUE on a line by itself (e.g. NVIDIA_API_KEY=nvapi-…) → save to <dataDir>/.env
 *   - JSON pasted into chat with a top-level "mcpServers" key → merge into <dataDir>/mcp.json
 */
/**
 * Try to find an email + password pair anywhere in the user's paste.
 * Liberal — accepts the various shapes people naturally type.
 */
function extractEmailPasswordPair(input) {
    const text = input.trim();
    // Don't run on long text (URLs in paragraphs, etc.) — only on short
    // credential-shaped pastes.
    if (text.length > 500 || text.split(/\s+/).length > 8)
        return null;
    // Find the email (max 1).
    const emailMatch = /([a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,})/i.exec(text);
    if (!emailMatch)
        return null;
    const email = emailMatch[1].toLowerCase();
    const beforeEmail = text.slice(0, emailMatch.index).trim();
    const afterEmail = text.slice(emailMatch.index + emailMatch[0].length).trim();
    // Strip common separators / labels next to the email.
    const stripPasswordWrapper = (s) => s
        .replace(/^[\s/:|,;\-=]+/, '')
        .replace(/[\s/:|,;\-=]+$/, '')
        .replace(/^(EMAIL_?PASSWORD|PASSWORD|PASS|GMAIL_?PASSWORD)\s*[:=]\s*/i, '')
        .replace(/^(EMAIL|EMAIL_FROM|GMAIL|EMAIL_ADDRESS)\s*[:=]\s*/i, '')
        .trim();
    // Look for a password after the email (most common), then before.
    let candidate = stripPasswordWrapper(afterEmail);
    if (candidate.length === 0)
        candidate = stripPasswordWrapper(beforeEmail);
    // The candidate may still contain the email label fragment. Strip out
    // any token that's the literal email or starts with EMAIL=. We can't
    // blanket-reject any token with '@' — real passwords frequently include
    // it (e.g. `Tasees@102030`). Compare against the extracted email instead.
    const tokens = candidate.split(/\s+/).filter(Boolean);
    const passwordTokens = tokens.filter(t => t.toLowerCase() !== email &&
        !/^EMAIL[=_:]/i.test(t));
    const password = passwordTokens.join(' ').trim();
    // A "password" that's still labeled like KEY=VAL — bail. The
    // single-line KEY=VALUE preprocessor will handle it.
    if (!password || password.length < 4 || /=/.test(password))
        return null;
    // Don't trigger for conversational sentences that happen to contain
    // an email address (e.g. "send email to ahmed@gmail.com saying hi").
    // The trailing space requirement lets labeled pastes like
    // "EMAIL=foo@..." through — those have no following space character
    // before the equals sign.
    if (/^(send|أرسل|ارسل|to|من|hi|hello)\s/i.test(text))
        return null;
    return { email, password };
}
export async function saveEmailCreds(email, password) {
    try {
        const dataDir = getDataDir();
        const envPath = join(dataDir, '.env');
        const existing = {};
        if (existsSync(envPath)) {
            for (const line of readFileSync(envPath, 'utf-8').split('\n')) {
                const m = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/.exec(line);
                if (m) {
                    let v = m[2].trim();
                    if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {
                        v = v.slice(1, -1);
                    }
                    existing[m[1]] = v;
                }
            }
        }
        existing['EMAIL'] = email;
        existing['EMAIL_FROM'] = email;
        existing['SMTP_USER'] = email;
        existing['SMTP_PASS'] = password;
        existing['EMAIL_PASSWORD'] = password;
        const provider = detectProvider(email);
        let providerNote = '';
        if (provider) {
            existing['SMTP_HOST'] = provider.smtpHost;
            existing['SMTP_PORT'] = String(provider.smtpPort);
            existing['SMTP_SECURE'] = provider.secure ? 'true' : 'false';
            providerNote = ` (${provider.label} → ${provider.smtpHost}:${provider.smtpPort})`;
        }
        mkdirSync(dataDir, { recursive: true });
        const body = Object.keys(existing).sort().map((k) => {
            const v = existing[k] ?? '';
            return `${k}=${/[\s#]/.test(v) ? `"${v.replace(/"/g, '\\"')}"` : v}`;
        }).join('\n') + '\n';
        writeFileSync(envPath, body, { mode: 0o600 });
        // Apply live so the very next "send email" turn uses these creds.
        for (const [k, v] of Object.entries(existing)) {
            process.env[k] = v;
        }
        const masked = password.length > 12 ? password.slice(0, 4) + '…' + password.slice(-3) : '•'.repeat(password.length);
        const isGmail = /@(gmail|googlemail)\.com$/i.test(email);
        return [
            `✅ تم حفظ بيانات البريد${providerNote}.`,
            `📧 \`${email}\` / كلمة المرور: \`${masked}\``,
            ``,
            `جرّب الآن: **"أرسل بريد لـ test@example.com بعنوان مرحبا"** وسأرسله لك.`,
            ``,
            isGmail
                ? `ℹ️ سأجرب SMTP أولاً. إذا رفض Gmail كلمة المرور (لأنها كلمة مرور عادية وعندك 2FA)، أحوّل ` +
                    `تلقائياً للدخول عبر متصفح Gmail (يتطلّب أحياناً تأكيد على الجوال أول مرة فقط).` +
                    `\nللدقة الأعلى: أنشئ App Password من https://myaccount.google.com/apppasswords ` +
                    `وألصقه كـ \`EMAIL_PASSWORD=…\``
                : `ℹ️ سأرسل عبر SMTP. لو رفض المزوّد كلمة المرور العادية، فعلياً تحتاج "App Password" من إعدادات الحساب.`,
        ].join('\n');
    }
    catch (err) {
        return `⚠️ تعذّر حفظ بيانات البريد: ${err instanceof Error ? err.message : String(err)}`;
    }
}
/**
 * Detect a Telegram BotFather token in free-form chat and auto-connect.
 * BotFather tokens are exactly: <numeric_bot_id>:<35-char alphanumeric_secret>
 *   • bot_id: 8–11 digits
 *   • separator: literal `:`
 *   • secret: 35 chars from [A-Za-z0-9_-]
 *
 * The user can paste in any of these natural phrasings:
 *   "هذا توكن البوت: 123456:ABC..."
 *   "telegram bot token = 123456:ABC..."
 *   "MUASSIS_TELEGRAM_BOT_TOKEN=123456:ABC..."
 *   just the bare token "123456:ABCdef..."
 *
 * Always validated via getMe before commit. On success we both:
 *   1. Save token as identity_fact channel.telegram_bot_token (encrypted)
 *   2. Start the long-poll session immediately — no restart needed
 */
async function tryTelegramTokenAutoConnect(message, tenantId) {
    const trimmed = message.trim();
    // Strip common prefixes the user might type around the token
    const stripped = trimmed
        .replace(/^.*?(?:token|توكن|telegram[^:]*):?\s*=?\s*/i, '')
        .trim();
    const tokenMatch = stripped.match(/^(\d{8,11}:[A-Za-z0-9_-]{35})/);
    if (!tokenMatch)
        return null;
    const token = tokenMatch[1];
    if (!tenantId) {
        return '🤖 وجدت توكن Telegram لكن ما عندي tenant — شغّل `muassis init` أولاً.';
    }
    try {
        const { startTelegramSession } = await import('../channels/telegram/adapter.js');
        const session = await startTelegramSession(tenantId, token);
        if (session.status === 'auth_failed') {
            return '🤖 التوكن غير صحيح — تأكد من نسخه من @BotFather مرة ثانية. القيمة التي أرسلتها لم تنجح في `getMe`.';
        }
        // Persist as encrypted identity fact (auto-encrypts via SENSITIVE_KEY_PATTERNS)
        const { upsertFact } = await import('../autonomy/identity.js');
        await upsertFact(tenantId, 'channel.telegram_bot_token', token, 'business', 'owner');
        const handle = session.botUsername ? `@${session.botUsername}` : 'البوت';
        return `🤖 تم! Telegram bot ${handle} متصل.

افتح Telegram، ابحث عن ${handle}، اضغط Start، وكلمني هناك مباشرة.
محفوظ مشفر في الجهاز — حتى إعادة تشغيل المؤسس ستبقى الجلسة قائمة.

دليل كامل: docs/CONNECT_TELEGRAM.md`;
    }
    catch (err) {
        log.warn({ tenantId, error: err.message }, 'telegram auto-connect failed');
        return `🤖 محاولة الاتصال فشلت: ${err.message}. حاول من Settings → Telegram في لوحة التحكم.`;
    }
}
async function tryQuickConfig(message) {
    const trimmed = message.trim();
    // ── 0. GitHub URL paste → try to learn from it ──
    // Highest-priority preprocessor — handles "drop a repo URL, install
    // its tools/skills." Goes through learn-from-url.ts which detects:
    //   • Direct .md link → save as skill
    //   • Repo with package.json + MCP signals → register as MCP server
    //   • Repo with /skills/ dir → download all .md files
    // Returns null and falls through to the LLM if we can't make sense
    // of the URL.
    const githubUrl = extractGitHubUrl(trimmed);
    if (githubUrl) {
        // Only intercept when the URL is the dominant content of the
        // message — not when the user is asking the agent to "look at" or
        // "summarize" a URL alongside other text. Heuristic: URL is >70%
        // of the trimmed message.
        if (githubUrl.length / trimmed.length > 0.7) {
            try {
                const result = await learnFromGitHubUrl(githubUrl);
                return result.reply;
            }
            catch (err) {
                log.warn({ url: githubUrl, error: err instanceof Error ? err.message : String(err) }, 'learn-from-url failed');
                // Fall through to LLM rather than swallowing
            }
        }
    }
    // ── 1. KEY=VALUE single-line paste ──
    // Stricter than a generic /=/ check: must be UPPER_SNAKE_CASE = something.
    // This way "name=ahmed" or arbitrary "x = 5" doesn't accidentally become
    // an env var. Multi-line pastes that BEGIN with such a line aren't matched
    // either — we want a clean single var.
    // ── Combined "email + password" paste, in any of these shapes:
    //   foo@gmail.com mypassword
    //   foo@gmail.com / mypassword
    //   foo@gmail.com\nmypassword
    //   EMAIL=foo@gmail.com PASSWORD=mypassword
    //   EMAIL=foo@gmail.com EMAIL_PASSWORD=mypassword
    //   foo@gmail.com:mypassword
    // We extract both, save them, and let the normal SMTP-then-browser
    // fallback figure out which auth path actually works.
    const combo = extractEmailPasswordPair(trimmed);
    if (combo) {
        return saveEmailCreds(combo.email, combo.password);
    }
    // ── 1a. Bare API-key paste (no KEY= prefix) ──
    // Detect frontier-provider API keys by their distinctive prefix and route
    // straight to the matching env var. Lets the user paste "sk-ant-xyz..."
    // without typing "ANTHROPIC_API_KEY=" first. Keeps the Muassis-style
    // friction-free UX OpenClaw never had.
    // Detect a provider API key by distinctive prefix. Works whether the user
    // PASTES just the key ("sk-ant-xyz...") OR mentions it inside a natural-
    // language sentence with slang/typos ("yo use my openai key sk-proj-xyz pls").
    // We test each anchored regex against a single token, then scan every
    // whitespace token in the message — so surrounding words never block it.
    const matchApiKey = (tok) => !tok ? null
        // Email + web-search providers
        : tok.match(/^(re_[A-Za-z0-9_-]{20,100})$/) ? { key: 'EMAIL_RESEND_API_KEY', label: 'Resend (Email)' }
        : tok.match(/^(tvly-[A-Za-z0-9_-]{20,60})$/) ? { key: 'TAVILY_API_KEY', label: 'Tavily (Web Search)' }
        : tok.match(/^(BSA[A-Za-z0-9_-]{20,60})$/) ? { key: 'BRAVE_SEARCH_API_KEY', label: 'Brave (Web Search)' }
        // Frontier LLM providers — most specific prefixes first
        : tok.match(/^(sk-ant-[A-Za-z0-9_-]{40,120})$/) ? { key: 'ANTHROPIC_API_KEY', label: 'Anthropic Claude' }
        : tok.match(/^(sk-or-v1-[a-f0-9]{40,80})$/i) ? { key: 'OPENROUTER_API_KEY', label: 'OpenRouter' }
        : tok.match(/^(sk-proj-[A-Za-z0-9_-]{60,200})$/) ? { key: 'OPENAI_API_KEY', label: 'OpenAI ChatGPT' }
        : tok.match(/^(sk-[A-Za-z0-9]{30,80})$/) && /\bdeepseek\b/i.test(message) ? { key: 'DEEPSEEK_API_KEY', label: 'DeepSeek' }
        : tok.match(/^(sk-[A-Za-z0-9]{30,80})$/) && /\b(moonshot|kimi)\b/i.test(message) ? { key: 'MOONSHOT_API_KEY', label: 'Moonshot Kimi' }
        : tok.match(/^(sk-[A-Za-z0-9]{30,80})$/) ? { key: 'OPENAI_API_KEY', label: 'OpenAI ChatGPT' }
        : tok.match(/^(AIza[A-Za-z0-9_-]{35})$/) ? { key: 'GOOGLE_AI_API_KEY', label: 'Google Gemini' }
        : tok.match(/^(nvapi-[A-Za-z0-9_-]{60,90})$/) ? { key: 'NVIDIA_NIM_API_KEY', label: 'NVIDIA NIM' }
        // Universal OpenAI-compatible providers
        : tok.match(/^(gsk_[A-Za-z0-9]{40,80})$/) ? { key: 'GROQ_API_KEY', label: 'Groq (LPU)' }
        : tok.match(/^(pplx-[A-Za-z0-9]{40,80})$/) ? { key: 'PERPLEXITY_API_KEY', label: 'Perplexity' }
        : tok.match(/^(xai-[A-Za-z0-9]{40,100})$/) ? { key: 'XAI_API_KEY', label: 'xAI Grok' }
        : tok.match(/^(tgp_v1_[A-Za-z0-9_-]{30,80})$/) ? { key: 'TOGETHER_API_KEY', label: 'Together AI' }
        : tok.match(/^(hf_[A-Za-z0-9]{30,80})$/) ? { key: 'HF_API_KEY', label: 'HuggingFace' }
        : tok.match(/^([A-Za-z0-9]{32})$/) && /\bmistral\b/i.test(message) ? { key: 'MISTRAL_API_KEY', label: 'Mistral' }
        : null;
    // Bare paste first (exact prior behavior), then every token in the sentence.
    const keyCandidates = [trimmed, ...message.split(/\s+/)
        .map((t) => t.replace(/^["'`(<\[]+|["'`)>\].,;:!?]+$/g, ''))
        .filter(Boolean)];
    let bareKeyMatch = null;
    for (const cand of keyCandidates) {
        const hit = matchApiKey(cand);
        if (hit) { bareKeyMatch = { key: hit.key, value: cand, label: hit.label }; break; }
    }
    if (bareKeyMatch) {
        try {
            const dataDir = getDataDir();
            const envPath = join(dataDir, '.env');
            const existing = {};
            if (existsSync(envPath)) {
                for (const line of readFileSync(envPath, 'utf-8').split('\n')) {
                    const m = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/.exec(line);
                    if (m)
                        existing[m[1]] = m[2].trim().replace(/^["']|["']$/g, '');
                }
            }
            existing[bareKeyMatch.key] = bareKeyMatch.value;
            // Reload into process.env immediately so failover sees the new key without restart
            process.env[bareKeyMatch.key] = bareKeyMatch.value;
            const text = Object.entries(existing).map(([k, v]) => `${k}=${v}`).join('\n') + '\n';
            writeFileSync(envPath, text, { encoding: 'utf-8', mode: 0o600 });
            // Trigger failover chain rebuild
            try {
                const { initFailover } = await import('../models/failover.js');
                await initFailover();
            }
            catch { /* best effort */ }
            return `🔑 تم! حفظت مفتاح **${bareKeyMatch.label}** (مشفر، صلاحية الملف 0600).\n\n` +
                `الآن المؤسس يستخدم ${bareKeyMatch.label} كمزود رئيسي.\n` +
                `لتغيير النموذج بالضبط: لوحة التحكم → الإعدادات → النماذج، أو أرسل "use claude opus" / "switch to gpt-4o".`;
        }
        catch (err) {
            return `⚠️ تعذّر حفظ المفتاح: ${err instanceof Error ? err.message : err}`;
        }
    }
    const kvMatch = /^([A-Z][A-Z0-9_]{2,})=(.+)$/.exec(trimmed);
    if (kvMatch && !trimmed.includes('\n')) {
        const [, key, rawValue] = kvMatch;
        const value = rawValue.trim().replace(/^["']|["']$/g, '');
        if (value.length === 0)
            return null;
        try {
            const dataDir = getDataDir();
            const envPath = join(dataDir, '.env');
            const existing = {};
            if (existsSync(envPath)) {
                for (const line of readFileSync(envPath, 'utf-8').split('\n')) {
                    const m = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/.exec(line);
                    if (m) {
                        let v = m[2].trim();
                        if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {
                            v = v.slice(1, -1);
                        }
                        existing[m[1]] = v;
                    }
                }
            }
            // ── Email auto-config: when user pastes EMAIL=address@gmail.com,
            // also fill SMTP_HOST/SMTP_PORT/SMTP_USER/EMAIL_FROM/SMTP_SECURE
            // based on a provider table. So the user only types the address +
            // password, never the SMTP host/port. ──
            let emailAutoConfigNote = '';
            const emailKeys = ['EMAIL', 'EMAIL_FROM', 'GMAIL', 'EMAIL_ADDRESS'];
            const passwordKeys = ['EMAIL_PASSWORD', 'EMAIL_PASS', 'GMAIL_PASSWORD', 'SMTP_PASS'];
            if (emailKeys.includes(key) && /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(value)) {
                existing['EMAIL_FROM'] = value;
                existing['SMTP_USER'] = value;
                const provider = detectProvider(value);
                if (provider) {
                    existing['SMTP_HOST'] = provider.smtpHost;
                    existing['SMTP_PORT'] = String(provider.smtpPort);
                    existing['SMTP_SECURE'] = provider.secure ? 'true' : 'false';
                    const haveSmtpPass = !!existing['SMTP_PASS'];
                    emailAutoConfigNote =
                        `\n📧 ضبطت إعدادات ${provider.label} تلقائياً (${provider.smtpHost}:${provider.smtpPort}).\n` +
                            (haveSmtpPass
                                ? `كل شي جاهز — جرّب طلب إرسال بريد للتأكد.`
                                : `الخطوة التالية: ألصق ${provider.appPasswordUrl ? 'App Password' : 'كلمة المرور'} هنا كـ \`EMAIL_PASSWORD=…\`.\n` +
                                    provider.authNote);
                }
                else {
                    emailAutoConfigNote =
                        `\n📧 ما عرفت مزوّد البريد لـ \`${value.split('@')[1]}\`. ` +
                            `إذا تعرفه، ألصق:\n` +
                            `\`SMTP_HOST=mail.your-provider.com\`\n` +
                            `\`SMTP_PORT=587\`\n` +
                            `\`EMAIL_PASSWORD=…\``;
                }
            }
            else if (passwordKeys.includes(key)) {
                existing['SMTP_PASS'] = value;
                // Mirror to the canonical name even if user typed an alias.
                const haveAddress = !!existing['EMAIL_FROM'];
                const haveHost = !!existing['SMTP_HOST'];
                if (haveAddress && haveHost) {
                    emailAutoConfigNote =
                        `\n📧 إعدادات البريد مكتملة (${existing['SMTP_USER']} عبر ${existing['SMTP_HOST']}). ` +
                            `جرّب: "أرسل بريد لـ test@example.com بعنوان مرحبا".`;
                }
                else if (!haveAddress) {
                    emailAutoConfigNote = `\n📧 احفظت كلمة المرور — يبقى أن تلصق العنوان: \`EMAIL=you@yourprovider.com\`.`;
                }
            }
            existing[key] = value;
            mkdirSync(dataDir, { recursive: true });
            const body = Object.keys(existing).sort().map((k) => {
                const v = existing[k] ?? '';
                return `${k}=${/[\s#]/.test(v) ? `"${v.replace(/"/g, '\\"')}"` : v}`;
            }).join('\n') + '\n';
            writeFileSync(envPath, body, { mode: 0o600 });
            // Apply to live process.env so the change takes effect for the
            // very next chat turn — no "muassis start" restart required for
            // env-driven tools (send_email, NIM key swap, etc.).
            for (const [k, v] of Object.entries(existing)) {
                process.env[k] = v;
            }
            const sensitive = /KEY|TOKEN|SECRET|PASSWORD|PASS$/i.test(key);
            const masked = sensitive && value.length > 12 ? value.slice(0, 6) + '…' + value.slice(-4) : value;
            return `✅ تم حفظ \`${key}\` في الإعدادات.\n` +
                `القيمة: ${sensitive ? '`' + masked + '`' : value}` +
                emailAutoConfigNote;
        }
        catch (err) {
            return `⚠️ تعذّر حفظ المتغيّر: ${err instanceof Error ? err.message : String(err)}`;
        }
    }
    // ── 2. mcp.json JSON paste ──
    if (trimmed.startsWith('{') && trimmed.includes('"mcpServers"')) {
        try {
            const parsed = JSON.parse(trimmed);
            if (parsed.mcpServers && typeof parsed.mcpServers === 'object') {
                const added = [];
                const failed = [];
                let totalNewTools = 0;
                // Hot-attach each entry: spawn the process, connect MCP, surface
                // tools to the brain on the next message — no restart required.
                const { mcpManager } = await import('../mcp/manager.js');
                for (const [name, cfg] of Object.entries(parsed.mcpServers)) {
                    if (!cfg?.command) {
                        failed.push({ name, error: 'missing "command" field' });
                        continue;
                    }
                    try {
                        const r = await mcpManager.addAndConnectServer(name, {
                            command: cfg.command,
                            args: cfg.args ?? [],
                            env: cfg.env,
                            description: cfg.description,
                        });
                        added.push(`${name} (${r.toolCount} tool${r.toolCount === 1 ? '' : 's'})`);
                        totalNewTools += r.toolCount;
                    }
                    catch (err) {
                        failed.push({ name, error: err.message });
                    }
                }
                const lines = [];
                if (added.length) {
                    lines.push(`✅ شغّلت ${added.length} خادم MCP فوراً (بدون إعادة تشغيل):`);
                    for (const a of added)
                        lines.push(`  • ${a}`);
                    lines.push(`\nالمجموع: ${totalNewTools} أداة جديدة جاهزة للاستخدام الآن.`);
                }
                if (failed.length) {
                    lines.push(``);
                    lines.push(`⚠️ فشل تسجيل ${failed.length}:`);
                    for (const f of failed)
                        lines.push(`  • ${f.name}: ${f.error}`);
                }
                return lines.join('\n');
            }
        }
        catch {
            // Not valid JSON — fall through to the LLM
        }
    }
    return null;
}
async function logMessage(params) {
    const id = randomUUID();
    await supabase.from('messages').insert({
        id,
        tenant_id: params.tenantId,
        customer_id: params.customerId,
        direction: params.direction,
        type: 'text',
        content: params.content,
        model_used: params.modelUsed ?? null,
        tokens_input: params.tokensInput ?? 0,
        tokens_output: params.tokensOutput ?? 0,
        cost_usd: params.costUsd ?? 0,
        response_time_ms: params.responseTimeMs ?? null,
        delivery_status: 'sent',
        created_at: new Date().toISOString(),
    });
    return id;
}
export async function registerWebChatRoutes(app) {
    app.post('/api/chat', {
        preHandler: [authMiddleware],
        config: { rateLimit: { max: 60, timeWindow: '1 minute' } },
    }, async (request, reply) => {
        try {
            const parsed = chatSchema.safeParse(request.body);
            if (!parsed.success) {
                return reply.code(400).send({
                    success: false,
                    error: 'Validation error',
                    details: parsed.error.issues.map(i => ({ field: i.path.join('.'), message: i.message })),
                });
            }
            const body = parsed.data;
            const user = request.user;
            const tenant = await resolveTenantForUser(user.id);
            if (!tenant) {
                return reply.code(404).send({
                    success: false,
                    error: 'No tenant configured for this device. Run muassis init first.',
                });
            }
            const customer = await ensureWebCustomer(tenant.id, body.customer_id, body.customer_name);
            // Deterministic fast path #0: system commands like "open Safari",
            // "screenshot", "open google.com" run via exec/screencapture
            // before the LLM gets involved. These are the requests qwen
            // keeps faking — bypassing the model entirely makes them
            // 100% reliable and instant. customer.id is passed so
            // last-opened-browser memory works across follow-ups. The last
            // assistant turn is also fetched so the referential-open matcher
            // ("open it" → opens the path that turn announced) has its
            // context.
            const lastAssistantResult = await supabase
                .from('messages')
                .select('content')
                .eq('tenant_id', tenant.id)
                .eq('customer_id', customer.id)
                .eq('direction', 'outbound')
                .order('created_at', { ascending: false })
                .limit(1);
            const lastAssistantMessage = lastAssistantResult.data?.[0]?.content ?? undefined;
            // Pass tenantId so the KG fact-recall shortcut works at the route
            // level too (saves a brain round-trip on "what's my X" queries).
            // In minimal/off mode, only KG lookups + truly-deterministic facts
            // intercept; everything else falls through to the brain.
            const quickAction = await tryQuickAction(body.message, customer.id, lastAssistantMessage, tenant.id);
            if (quickAction) {
                await logMessage({
                    tenantId: tenant.id,
                    customerId: customer.id,
                    direction: 'inbound',
                    content: body.message,
                });
                const replyId = await logMessage({
                    tenantId: tenant.id,
                    customerId: customer.id,
                    direction: 'outbound',
                    content: quickAction,
                    modelUsed: 'muassis-quick-action',
                });
                return reply.send({
                    success: true,
                    data: {
                        reply: quickAction,
                        customer_id: customer.id,
                        message_id: replyId,
                        agent_name: tenant.agent_name,
                        tokens: { input: 0, output: 0 },
                        latency_ms: 0,
                        model: 'muassis-quick-action',
                    },
                });
            }
            // Quick-config short-circuit: if the user pasted KEY=VALUE, an
            // mcp.json snippet, or a GitHub repo / .md URL, handle it locally
            // without burning an LLM round-trip + tokens. The reply is logged
            // as a normal assistant turn so the conversation stays coherent.
            // Telegram BotFather token comes first since it's a strict pattern
            // and must auto-connect before falling through to KV / GitHub paths.
            const tg = await tryTelegramTokenAutoConnect(body.message, tenant.id);
            const quick = tg ?? await tryQuickConfig(body.message);
            if (quick) {
                await logMessage({
                    tenantId: tenant.id,
                    customerId: customer.id,
                    direction: 'inbound',
                    content: body.message,
                });
                const replyId = await logMessage({
                    tenantId: tenant.id,
                    customerId: customer.id,
                    direction: 'outbound',
                    content: quick,
                    modelUsed: 'muassis-quick-config',
                });
                return reply.send({
                    success: true,
                    data: {
                        reply: quick,
                        customer_id: customer.id,
                        message_id: replyId,
                        agent_name: tenant.agent_name,
                        tokens: { input: 0, output: 0 },
                        latency_ms: 0,
                        model: 'muassis-quick-config',
                    },
                });
            }
            // Pull the last ~10 messages BEFORE inserting the new one — otherwise
            // brain.ts would see the current message twice (once in history, once
            // as the trailing user turn it appends).
            const recentResult = await supabase
                .from('messages')
                .select('direction, content')
                .eq('tenant_id', tenant.id)
                .eq('customer_id', customer.id)
                .order('created_at', { ascending: false })
                .limit(10);
            const recentMessages = (recentResult.data ?? [])
                .reverse()
                .filter((r) => r.content && r.content.trim().length > 0)
                .map((r) => ({
                role: (r.direction === 'inbound' ? 'user' : 'assistant'),
                content: r.content,
            }));
            // Persist the inbound message so it survives even if the agent fails.
            await logMessage({
                tenantId: tenant.id,
                customerId: customer.id,
                direction: 'inbound',
                content: body.message,
            });
            // Bump customer activity (last_contact + total_messages).
            // (Audit M11) Defense-in-depth: scope by tenant_id even though
            // ensureWebCustomer already enforces tenant. Cheap to add, blocks
            // a class of forgery bugs if customer.id is ever derived from the
            // request body in the future.
            await supabase
                .from('customers')
                .update({ last_contact: new Date().toISOString() })
                .eq('id', customer.id)
                .eq('tenant_id', tenant.id);
            const brainResp = await processMessage(tenant.id, customer.id, body.message, {
                mediaType: 'text',
                channel: 'web',
                customerName: customer.name ?? body.customer_name,
                recentMessages,
            });
            const replyMessageId = await logMessage({
                tenantId: tenant.id,
                customerId: customer.id,
                direction: 'outbound',
                content: brainResp.response,
                modelUsed: brainResp.model_used,
                tokensInput: brainResp.tokens.input,
                tokensOutput: brainResp.tokens.output,
                costUsd: brainResp.cost,
                responseTimeMs: brainResp.latencyMs,
            });
            // (C5) Await the raw-stub write so turn N+1 sees turn N's entities.
            // Internal: recordRawEpisode (sync, ~30ms) is awaited; the slow
            // LLM-based addConversationEpisode is still fire-and-forget inside.
            await learnFromConversation(tenant.id, customer.id, customer.name ?? body.customer_name ?? 'Web user', `User: ${body.message}\nMuassis: ${brainResp.response}`).catch((err) => log.debug({ err: err.message }, 'learn-from-conversation outer failed'));
            return reply.send({
                success: true,
                data: {
                    reply: brainResp.response,
                    customer_id: customer.id,
                    message_id: replyMessageId,
                    agent_name: tenant.agent_name,
                    tokens: brainResp.tokens,
                    latency_ms: brainResp.latencyMs,
                    model: brainResp.model_used,
                },
            });
        }
        catch (err) {
            log.error({ error: err instanceof Error ? err.message : String(err) }, 'web chat failed');
            return reply.code(500).send({ success: false, error: 'Internal server error' });
        }
    });
    // ─── SSE streaming endpoint ────────────────────────────────────────
    // Dashboard subscribes to /api/chat/stream while POSTing to /api/chat —
    // events flow as the brain processes (skill match → tool calls → final
    // response). Without this the dashboard is opaque "spinner → wall of
    // text" — the OpenClaw "feel alive" gap.
    app.post('/api/chat/stream', {
        preHandler: [authMiddleware],
        config: { rateLimit: { max: 30, timeWindow: '1 minute' } },
    }, async (request, reply) => {
        const parsed = chatSchema.safeParse(request.body);
        if (!parsed.success) {
            return reply.code(400).send({ success: false, error: 'Validation error' });
        }
        const body = parsed.data;
        const user = request.user;
        const tenant = await resolveTenantForUser(user.id);
        if (!tenant) {
            return reply.code(404).send({ success: false, error: 'No tenant configured' });
        }
        const customer = await ensureWebCustomer(tenant.id, body.customer_id, body.customer_name);
        reply.raw.writeHead(200, {
            'Content-Type': 'text/event-stream',
            'Cache-Control': 'no-cache, no-transform',
            Connection: 'keep-alive',
            'X-Accel-Buffering': 'no',
        });
        // Track client disconnect so the brain can short-circuit downstream
        // tool calls instead of burning tokens with no destination. (Audit H2.)
        let clientGone = false;
        const onClose = () => { clientGone = true; };
        reply.raw.on('close', onClose);
        reply.raw.on('error', onClose);
        const send = (event, data) => {
            if (clientGone)
                return; // skip writes once socket is closed
            let body;
            try {
                body = JSON.stringify(data);
            }
            catch (jsonErr) {
                // (Audit H1) Don't lose the event silently — tell the client a
                // serialization failure happened.
                log.warn({ event, err: jsonErr.message }, 'SSE event JSON.stringify failed');
                body = JSON.stringify({ _serialization_error: jsonErr.message });
            }
            try {
                reply.raw.write(`event: ${event}\n`);
                reply.raw.write(`data: ${body}\n\n`);
            }
            catch (writeErr) {
                // Socket closed mid-write — flag clientGone so subsequent calls bail.
                log.debug({ event, err: writeErr.message }, 'SSE write failed — client disconnected');
                clientGone = true;
            }
        };
        send('open', { tenant_id: tenant.id, customer_id: customer.id });
        // SSE keep-alive — flush a comment frame every 15s so intermediate
        // proxies (and the Tauri WebKit client) don't decide the connection
        // is idle and tear it down. WebKit's network stack will drop an SSE
        // socket after ~60s of silence even if requestTimeout is disabled
        // server-side. A bare ":\n\n" line is the standard EventSource heart-
        // beat that browsers ignore but keeps the socket warm.
        const keepAlive = setInterval(() => {
            if (clientGone)
                return;
            try {
                reply.raw.write(': keep-alive\n\n');
            }
            catch (err) {
                // Write after socket close — flag clientGone, the next send will
                // bail. Don't crash the handler.
                clientGone = true;
            }
        }, 15_000);
        reply.raw.on('close', () => clearInterval(keepAlive));
        try {
            await logMessage({
                tenantId: tenant.id,
                customerId: customer.id,
                direction: 'inbound',
                content: body.message,
            });
            const recentResult = await supabase
                .from('messages')
                .select('direction, content')
                .eq('tenant_id', tenant.id)
                .eq('customer_id', customer.id)
                .order('created_at', { ascending: false })
                .limit(10);
            const recentMessages = (recentResult.data ?? [])
                .reverse()
                .filter((r) => r.content && r.content.trim().length > 0)
                .map((r) => ({
                role: (r.direction === 'inbound' ? 'user' : 'assistant'),
                content: r.content,
            }));
            const brainResp = await processMessage(tenant.id, customer.id, body.message, {
                mediaType: 'text',
                channel: 'web',
                customerName: customer.name ?? body.customer_name,
                recentMessages,
            }, {
                budgetAware: true,
                enableTools: true,
                maxToolLoops: 20,
                onEvent: (ev) => {
                    if (clientGone)
                        return; // (Audit H2) stop emitting once disconnected
                    send(ev.type, ev);
                },
            });
            const replyMessageId = await logMessage({
                tenantId: tenant.id,
                customerId: customer.id,
                direction: 'outbound',
                content: brainResp.response,
                modelUsed: brainResp.model_used,
                tokensInput: brainResp.tokens.input,
                tokensOutput: brainResp.tokens.output,
                costUsd: brainResp.cost,
                responseTimeMs: brainResp.latencyMs,
            });
            await learnFromConversation(tenant.id, customer.id, customer.name ?? body.customer_name ?? 'Web user', `User: ${body.message}\nMuassis: ${brainResp.response}`).catch((err) => log.debug({ err: err.message }, 'SSE learn-from-conversation outer failed'));
            send('response', { text: brainResp.response, message_id: replyMessageId });
            send('done', {
                latency_ms: brainResp.latencyMs,
                tokens: brainResp.tokens,
                model: brainResp.model_used,
                tools_executed: brainResp.toolsExecuted ?? [],
            });
        }
        catch (err) {
            const msg = err instanceof Error ? err.message : String(err);
            log.error({ err: msg, tenantId: tenant.id }, 'SSE chat handler threw');
            send('error', { message: msg });
        }
        finally {
            clearInterval(keepAlive);
            reply.raw.removeListener('close', onClose);
            reply.raw.removeListener('error', onClose);
            try {
                reply.raw.end();
            }
            catch (endErr) {
                log.debug({ err: endErr.message }, 'SSE end() failed (already closed)');
            }
        }
    });
    // (v2.5.5) GET /api/artifact?path=... — stream a file from disk back to
    // the dashboard. Used by inline artifact previews (PDF iframe, image
    // src, etc.). Auth-gated so we don't expose arbitrary local files; the
    // path must be inside the tenant's workspace dir or the standard OS
    // download dirs (Desktop/Documents/Downloads).
    app.get('/api/artifact', {
        // (v3.0.5) Custom preHandler that ALSO accepts the bearer via
        // ?token= query string. Browser tabs opened from the chat artifact
        // card can't send an Authorization header, so without this the
        // "افتح PDF" button looked like a ghost button (the new tab
        // 401'd silently and the user just saw a blank page).
        preHandler: [
            async (req, _reply) => {
                const q = req.query;
                if (q?.token && !req.headers.authorization) {
                    req.headers.authorization = `Bearer ${q.token}`;
                }
                return authMiddleware(req, _reply);
            },
        ],
        config: { rateLimit: { max: 240, timeWindow: '1 minute' } },
    }, async (request, reply) => {
        const q = request.query;
        const raw = q?.path;
        if (!raw || typeof raw !== 'string') {
            return reply.code(400).send({ success: false, error: 'missing path' });
        }
        // Defense: refuse anything that isn't an absolute path or contains
        // `..` traversal. We let the agent's tools write anywhere they like,
        // so the auth gate is what keeps random remote callers from reading
        // /etc/passwd via /api/artifact?path=/etc/passwd. Authenticated
        // local users CAN read anywhere — that's by design.
        const { resolve, isAbsolute } = await import('node:path');
        const { stat, readFile } = await import('node:fs/promises');
        if (raw.includes('..')) {
            return reply.code(400).send({ success: false, error: 'path traversal not allowed' });
        }
        const home = process.env.HOME ?? process.env.USERPROFILE ?? '';
        const resolved = raw.startsWith('~/') && home ? resolve(home, raw.slice(2)) : resolve(raw);
        if (!isAbsolute(resolved)) {
            return reply.code(400).send({ success: false, error: 'path must be absolute' });
        }
        try {
            const s = await stat(resolved);
            if (!s.isFile()) {
                return reply.code(404).send({ success: false, error: 'not a file' });
            }
            const buf = await readFile(resolved);
            const ext = resolved.split('.').pop()?.toLowerCase() ?? '';
            const mimeMap = {
                pdf: 'application/pdf', png: 'image/png', jpg: 'image/jpeg',
                jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp',
                svg: 'image/svg+xml', html: 'text/html', htm: 'text/html',
                txt: 'text/plain', md: 'text/markdown', json: 'application/json',
                mp3: 'audio/mpeg', wav: 'audio/wav', mp4: 'video/mp4',
                mov: 'video/quicktime', webm: 'video/webm',
                xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
                xls: 'application/vnd.ms-excel',
                docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
                doc: 'application/msword',
                pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
                ppt: 'application/vnd.ms-powerpoint',
            };
            const mime = mimeMap[ext] || 'application/octet-stream';
            reply.header('Content-Type', mime);
            reply.header('Content-Length', String(buf.length));
            reply.header('Cache-Control', 'private, max-age=60');
            return reply.send(buf);
        }
        catch (err) {
            const msg = err instanceof Error ? err.message : String(err);
            return reply.code(404).send({ success: false, error: msg });
        }
    });
    // (v3.0.1) GET /api/chat/recent?since=<ISO> — fetch outbound messages
    // posted to this user's default web customer since the given timestamp.
    // The dashboard polls this every 8s so that background-emitted messages
    // (subagent_result, cron-job firings, daily-report generations) show up
    // in the chat thread as if Moasis just said them. Without this poll,
    // those messages were written to the DB and never surfaced — the
    // "subagent ran but Moasis never told me" bug.
    app.get('/api/chat/recent', {
        preHandler: [authMiddleware],
        config: { rateLimit: { max: 600, timeWindow: '1 minute' } },
    }, async (request, reply) => {
        const user = request.user;
        const tenant = await resolveTenantForUser(user.id);
        if (!tenant) {
            return reply.code(404).send({ success: false, error: 'No tenant configured' });
        }
        const q = request.query;
        const sinceIso = q.since ?? new Date(Date.now() - 60 * 60 * 1000).toISOString();
        const limit = Math.min(parseInt(q.limit ?? '50', 10) || 50, 500);
        // (v3.0.1) `direction=both` returns the full conversation thread
        // (inbound + outbound) for chat-history bootstrap. Default is
        // outbound-only — that's what the background-message poller wants.
        const direction = q.direction === 'both' ? 'both'
            : q.direction === 'inbound' ? 'inbound' : 'outbound';
        // If caller supplies customer_id we honor it; otherwise we resolve
        // the OWNER'S primary chat thread — the customer with the most
        // messages, excluding the system-cron pseudo-customer (cron
        // heartbeats inflate last_contact and would otherwise hijack the
        // history).
        let customerId = q.customer_id;
        if (!customerId) {
            // Count messages per customer, pick the highest. SQLite COUNT
            // aggregation through the shim works on the messages table.
            const { data: counts } = await supabase
                .from('messages')
                .select('customer_id')
                .eq('tenant_id', tenant.id)
                .limit(2000);
            if (counts && counts.length > 0) {
                const tally = new Map();
                for (const row of counts) {
                    if (!row.customer_id)
                        continue;
                    tally.set(row.customer_id, (tally.get(row.customer_id) ?? 0) + 1);
                }
                // Filter out the cron pseudo-customer
                const { data: cronRow } = await supabase
                    .from('customers')
                    .select('id')
                    .eq('tenant_id', tenant.id)
                    .eq('phone', 'system-cron')
                    .maybeSingle();
                const cronId = cronRow?.id;
                if (cronId)
                    tally.delete(cronId);
                let bestId;
                let bestCount = 0;
                for (const [cid, c] of tally) {
                    if (c > bestCount) {
                        bestId = cid;
                        bestCount = c;
                    }
                }
                customerId = bestId;
            }
            // Final fallback: most-recent customer ignoring cron
            if (!customerId) {
                const { data } = await supabase
                    .from('customers')
                    .select('id, phone')
                    .eq('tenant_id', tenant.id)
                    .order('last_contact', { ascending: false })
                    .limit(5);
                const list = data ?? [];
                customerId = list.find((c) => c.phone !== 'system-cron')?.id;
            }
        }
        if (!customerId) {
            return reply.send({ success: true, data: { messages: [] } });
        }
        let query = supabase
            .from('messages')
            .select('id, direction, content, session_id, type, created_at')
            .eq('tenant_id', tenant.id)
            .eq('customer_id', customerId)
            .gt('created_at', sinceIso)
            .order('created_at', { ascending: true })
            .limit(limit);
        if (direction !== 'both') {
            query = query.eq('direction', direction);
        }
        const { data: rows } = await query;
        const messages = (rows ?? []).map((r) => ({
            id: r.id,
            direction: r.direction,
            content: r.content,
            type: r.type,
            created_at: r.created_at,
            // Mark the source so the dashboard can show a little badge
            // (subagent / cron / daily_report / chat). session_id prefix is
            // the source-of-truth set by the writer.
            source: typeof r.session_id === 'string' && r.session_id.startsWith('subagent:') ? 'subagent'
                : typeof r.session_id === 'string' && r.session_id.startsWith('cron:') ? 'cron'
                    : typeof r.session_id === 'string' && r.session_id.startsWith('daily_report:') ? 'daily_report'
                        : 'chat',
        }));
        return reply.send({ success: true, data: { messages, customer_id: customerId, server_time: new Date().toISOString() } });
    });
    // POST /api/voice/transcribe — multipart audio → text (Whisper).
    // Used by the dashboard mic button (MediaRecorder webm/ogg → blob → POST).
    // Browser-native SpeechRecognition is unreliable inside Tauri WKWebView;
    // backend Whisper is portable and accurate. Requires OPENAI_API_KEY in
    // dataDir/.env — returns a friendly 503 with a setup hint if missing.
    app.post('/api/voice/transcribe', {
        preHandler: [authMiddleware],
        config: { rateLimit: { max: 120, timeWindow: '1 minute' } },
    }, async (request, reply) => {
        try {
            const file = await request.file();
            if (!file) {
                return reply.code(400).send({ success: false, error: 'No audio uploaded' });
            }
            const { tmpdir } = await import('node:os');
            const { join: pathJoin } = await import('node:path');
            const { writeFile: fsWriteFile, unlink, mkdtemp } = await import('node:fs/promises');
            const tmpDir = await mkdtemp(pathJoin(tmpdir(), 'moasis-stt-'));
            const ext = (file.filename || 'audio.webm').split('.').pop() || 'webm';
            const tmpPath = pathJoin(tmpDir, `audio.${ext}`);
            const chunks = [];
            for await (const chunk of file.file) chunks.push(chunk);
            await fsWriteFile(tmpPath, Buffer.concat(chunks));
            try {
                const { transcribeAudio } = await import('../media/voice.js');
                const lang = ((request.query || {}).lang || 'ar').toString();
                const result = await transcribeAudio(tmpPath, lang);
                return reply.send({ success: true, data: { text: result.text } });
            } catch (transErr) {
                const msg = transErr instanceof Error ? transErr.message : String(transErr);
                if (/OPENAI_API_KEY/i.test(msg)) {
                    return reply.code(503).send({
                        success: false,
                        error: 'STT يحتاج مفتاح OpenAI Whisper. أضف السطر التالي للإعدادات: OPENAI_API_KEY=sk-...',
                        setup_hint: 'add OPENAI_API_KEY to ~/Library/Application Support/AlMoasis/.env',
                    });
                }
                return reply.code(502).send({ success: false, error: msg });
            } finally {
                try { await unlink(tmpPath); } catch { /* ignore */ }
            }
        } catch (err) {
            log.error({ err: err instanceof Error ? err.message : String(err) }, 'transcribe failed');
            return reply.code(500).send({ success: false, error: err instanceof Error ? err.message : String(err) });
        }
    });
    log.info('Web chat routes registered ✅');
}
//# sourceMappingURL=routes.js.map