167 lines
5.0 KiB
JavaScript
Executable File
167 lines
5.0 KiB
JavaScript
Executable File
#!/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:3100/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 = [
|
||
'Ты отвечаешь клиентам во ВКонтакте от имени дружелюбного менеджера.',
|
||
'Пиши на русском, коротко и по делу, без воды.',
|
||
'Не выдумывай цены и наличие. Если данных не хватает — задай минимально нужные уточнения.',
|
||
'Всегда завершай мягким следующим шагом.',
|
||
].join(' ');
|
||
|
||
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 '';
|
||
}
|
||
|
||
async function askOpenClaw(payload) {
|
||
const userText = String(payload?.text || '').trim();
|
||
if (!userText) return { silent: true };
|
||
|
||
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 reply = extractText(json);
|
||
if (!reply) return { silent: true };
|
||
return { reply };
|
||
} 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}`);
|
||
});
|