Files
openclaw/skills/vk-gateway/vk-endpoint.mjs
2026-02-23 11:01:28 +00:00

208 lines
7.6 KiB
JavaScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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}`);
});