diff --git a/skills/dist/vk-bridge.skill b/skills/dist/vk-bridge.skill index 3148af1..55bf7c7 100644 Binary files a/skills/dist/vk-bridge.skill and b/skills/dist/vk-bridge.skill differ diff --git a/skills/vk-bridge/SKILL.md b/skills/vk-bridge/SKILL.md index cbfa0b5..be5d8c9 100644 --- a/skills/vk-bridge/SKILL.md +++ b/skills/vk-bridge/SKILL.md @@ -9,15 +9,18 @@ description: "Интеграция ВКонтакте (сообщество) ч ## Что входит -- `scripts/vk-longpoll-bridge.mjs` — раннер Long Poll + отправка ответов. +- `scripts/vk-longpoll-bridge.mjs` — раннер Long Poll + отправка ответов в VK. +- `scripts/vk-openclaw-endpoint.mjs` — локальный HTTP endpoint `/vk/inbound`, который проксирует сообщения в OpenClaw `/v1/responses`. - `references/.env.example` — пример конфигурации. ## Быстрый запуск 1. Скопируй конфиг: - `cp skills/vk-bridge/references/.env.example .env.vk` -2. Заполни токен сообщества и URL обработчика. -3. Запусти: +2. Заполни `VK_TOKEN` и `OPENCLAW_GATEWAY_TOKEN`. +3. Запусти локальный endpoint: + - `node skills/vk-bridge/scripts/vk-openclaw-endpoint.mjs --env .env.vk` +4. В отдельном процессе запусти Long Poll worker: - `node skills/vk-bridge/scripts/vk-longpoll-bridge.mjs --env .env.vk` ## Требования @@ -26,6 +29,7 @@ description: "Интеграция ВКонтакте (сообщество) ч - Включённые сообщения сообщества в настройках группы. - Long Poll API включён для сообщества. - Локальный HTTP-обработчик, который принимает входящее сообщение и возвращает текст ответа. +- В Gateway включён HTTP endpoint `POST /v1/responses` и известен токен доступа. ## Протокол AI-обработчика diff --git a/skills/vk-bridge/references/.env.example b/skills/vk-bridge/references/.env.example index 44d7d0f..915a8a3 100644 --- a/skills/vk-bridge/references/.env.example +++ b/skills/vk-bridge/references/.env.example @@ -4,10 +4,21 @@ VK_TOKEN=vk1.a.your_group_token_here VK_API_VERSION=5.199 VK_WAIT=25 -# Local bridge endpoint (your OpenClaw-side HTTP handler) +# Local bridge endpoint (HTTP server from vk-openclaw-endpoint.mjs) OPENCLAW_BRIDGE_URL=http://127.0.0.1:8787/vk/inbound OPENCLAW_BRIDGE_TIMEOUT_MS=45000 +# OpenClaw Responses API (for vk-openclaw-endpoint.mjs) +OPENCLAW_RESPONSES_URL=http://127.0.0.1:3100/v1/responses +OPENCLAW_GATEWAY_TOKEN=replace_with_gateway_token +OPENCLAW_AGENT_MODEL=openclaw:main +OPENCLAW_TIMEOUT_MS=60000 + +# Local endpoint bind +VK_ENDPOINT_HOST=127.0.0.1 +VK_ENDPOINT_PORT=8787 +VK_ENDPOINT_PATH=/vk/inbound + # Optional # Comma-separated list of event types to process in bridge loop. VK_ALLOWED_EVENTS=message_new diff --git a/skills/vk-bridge/scripts/vk-openclaw-endpoint.mjs b/skills/vk-bridge/scripts/vk-openclaw-endpoint.mjs new file mode 100755 index 0000000..96af407 --- /dev/null +++ b/skills/vk-bridge/scripts/vk-openclaw-endpoint.mjs @@ -0,0 +1,166 @@ +#!/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}`); +});