#!/usr/bin/env node import fs from 'node:fs'; import http from 'node:http'; function parseArgs() { const args = process.argv.slice(2); const out = { env: null }; for (let i = 0; i < args.length; i++) { if (args[i] === '--env') out.env = args[++i]; } return out; } function loadEnv(path) { if (!path) return; const txt = fs.readFileSync(path, 'utf8'); for (const line of txt.split(/\r?\n/)) { const t = line.trim(); if (!t || t.startsWith('#')) continue; const idx = t.indexOf('='); if (idx === -1) continue; const k = t.slice(0, idx).trim(); const v = t.slice(idx + 1).trim(); if (!(k in process.env)) process.env[k] = v; } } const { env } = parseArgs(); loadEnv(env); const CFG = { bindHost: process.env.VK_ENDPOINT_HOST || '127.0.0.1', bindPort: Number(process.env.VK_ENDPOINT_PORT || 8787), path: process.env.VK_ENDPOINT_PATH || '/vk/inbound', openclawUrl: process.env.OPENCLAW_RESPONSES_URL || 'http://127.0.0.1:18789/v1/responses', openclawToken: process.env.OPENCLAW_GATEWAY_TOKEN || '', agentModel: process.env.OPENCLAW_AGENT_MODEL || 'openclaw:main', maxBodyBytes: Number(process.env.VK_ENDPOINT_MAX_BODY_BYTES || 1_000_000), timeoutMs: Number(process.env.OPENCLAW_TIMEOUT_MS || 60000), }; if (!CFG.openclawToken) { console.error('Missing OPENCLAW_GATEWAY_TOKEN'); process.exit(1); } const SYSTEM_INSTRUCTIONS = [ 'Ты отвечаешь клиентам во ВКонтакте как менеджер по бронированию размещения.', 'Всегда общайся на русском, коротко, дружелюбно и с лёгким юмором.', 'Твоя основная задача: переводить диалог к бронированию номеров и подбору вариантов.', 'Для расчётов и логики бронирования используй подход paradiz: даты, состав гостей, подходящие номера/связки номеров. Питания нет.', 'Если вопрос технический (серверы, файлы, скрипты, доступы, установка, админка, API), не обсуждай детали и не давай инструкций.', 'На технические вопросы мягко отшучивайся и сразу возвращай разговор к бронированию.', 'Не выдумывай цены и наличие. Если данных не хватает — запрашивай только минимально нужные поля.', 'Если бронь подтверждена и сформирован лист брони, можешь вернуть путь к файлу.', 'Отвечай строго JSON-объектом без markdown: {"reply":"...","send_booking_doc":false,"booking_doc_path":""}.', ].join(' '); const TECH_RE = /(сервер|файл|скрипт|ssh|linux|docker|git|github|api|токен|ключ|доступ|настрой|установ|деплой|хост|база|sql|конфиг|systemd|терминал|команд)/i; function buildTechRedirectReply() { const variants = [ 'С техникой у меня режим «тишина в серверной» 😄 Зато с радостью подберу бронь: напишите даты и сколько гостей?', 'Про файлы и серверы сегодня не болтаю — берегу магию отдыха ✨ Давайте лучше подберём номер: какие даты и состав гостей?', 'Техвопросы пропускаю с улыбкой 😉 А вот с бронью помогу сразу: когда планируете заезд и сколько будет взрослых/детей?', ]; return variants[Math.floor(Math.random() * variants.length)]; } function readBody(req, limit) { return new Promise((resolve, reject) => { let size = 0; const chunks = []; req.on('data', (c) => { size += c.length; if (size > limit) { reject(new Error('Payload too large')); req.destroy(); return; } chunks.push(c); }); req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))); req.on('error', reject); }); } function extractText(responseJson) { if (typeof responseJson?.output_text === 'string' && responseJson.output_text.trim()) { return responseJson.output_text.trim(); } const out = responseJson?.output; if (!Array.isArray(out)) return ''; for (const item of out) { if (item?.type !== 'message' || !Array.isArray(item?.content)) continue; for (const c of item.content) { if (c?.type === 'output_text' && typeof c.text === 'string' && c.text.trim()) { return c.text.trim(); } } } return ''; } function parseAssistantPayload(text) { const raw = String(text || '').trim(); if (!raw) return { reply: '' }; try { const parsed = JSON.parse(raw); if (parsed && typeof parsed === 'object') { return { reply: String(parsed.reply || '').trim(), send_booking_doc: Boolean(parsed.send_booking_doc), booking_doc_path: String(parsed.booking_doc_path || '').trim(), }; } } catch { // plain text fallback } return { reply: raw }; } async function askOpenClaw(payload) { const userText = String(payload?.text || '').trim(); if (!userText) return { silent: true }; if (TECH_RE.test(userText)) { return { reply: buildTechRedirectReply() }; } const reqBody = { model: CFG.agentModel, user: `vk:${payload.group_id}:${payload.user_id}`, max_output_tokens: 350, instructions: SYSTEM_INSTRUCTIONS, input: [ { type: 'message', role: 'user', content: [ { type: 'input_text', text: `VK message from user ${payload.user_id}: ${userText}`, }, ], }, ], }; const ctrl = new AbortController(); const t = setTimeout(() => ctrl.abort(), CFG.timeoutMs); try { const res = await fetch(CFG.openclawUrl, { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${CFG.openclawToken}`, }, body: JSON.stringify(reqBody), signal: ctrl.signal, }); if (!res.ok) { const text = await res.text().catch(() => ''); throw new Error(`OpenClaw HTTP ${res.status} ${text}`.trim()); } const json = await res.json(); const text = extractText(json); const payloadOut = parseAssistantPayload(text); if (!payloadOut.reply) return { silent: true }; return payloadOut; } finally { clearTimeout(t); } } const server = http.createServer(async (req, res) => { if (req.method === 'GET' && req.url === '/healthz') { res.writeHead(200, { 'content-type': 'application/json' }); res.end(JSON.stringify({ ok: true })); return; } if (req.method !== 'POST' || req.url !== CFG.path) { res.writeHead(404, { 'content-type': 'application/json' }); res.end(JSON.stringify({ error: 'not_found' })); return; } try { const raw = await readBody(req, CFG.maxBodyBytes); const body = JSON.parse(raw || '{}'); const out = await askOpenClaw(body); res.writeHead(200, { 'content-type': 'application/json' }); res.end(JSON.stringify(out)); } catch (e) { res.writeHead(500, { 'content-type': 'application/json' }); res.end(JSON.stringify({ error: 'bridge_failed', detail: e.message })); } }); server.listen(CFG.bindPort, CFG.bindHost, () => { console.log(`[vk-endpoint] listening on http://${CFG.bindHost}:${CFG.bindPort}${CFG.path}`); });