diff --git a/skills/dist/vk-bridge.skill b/skills/dist/vk-bridge.skill new file mode 100644 index 0000000..72c04cf Binary files /dev/null and b/skills/dist/vk-bridge.skill differ diff --git a/skills/vk-bridge/SKILL.md b/skills/vk-bridge/SKILL.md new file mode 100644 index 0000000..cd78c18 --- /dev/null +++ b/skills/vk-bridge/SKILL.md @@ -0,0 +1,53 @@ +--- +name: vk-bridge +description: "Интеграция ВКонтакте (сообщество) через Bots Long Poll API: слушать message_new, извлекать текст/метаданные, проксировать запрос в локальный AI-обработчик и отправлять ответ в VK через messages.send. Использовать, когда нужно подключить входящие сообщения VK к ассистенту и отвечать пользователям автоматически." +--- + +# VK Bridge + +Подключай сообщество VK к локальному обработчику ответов через Long Poll. + +## Что входит + +- `scripts/vk-longpoll-bridge.mjs` — раннер Long Poll + отправка ответов. +- `references/.env.example` — пример конфигурации. + +## Быстрый запуск + +1. Скопируй конфиг: + - `cp skills/vk-bridge/references/.env.example .env.vk` +2. Заполни токен сообщества и URL обработчика. +3. Запусти: + - `node skills/vk-bridge/scripts/vk-longpoll-bridge.mjs --env .env.vk` + +## Требования + +- Токен сообщества VK с правами `messages`. +- Включённые сообщения сообщества в настройках группы. +- Long Poll API включён для сообщества. +- Локальный HTTP-обработчик, который принимает входящее сообщение и возвращает текст ответа. + +## Протокол AI-обработчика + +Bridge делает POST на `OPENCLAW_BRIDGE_URL` с JSON: + +- `source`: `vk` +- `group_id` +- `user_id` +- `peer_id` +- `text` +- `payload` +- `attachments` +- `raw` + +Ожидает JSON-ответ: + +- `reply` (string) — текст для отправки в VK. +- `silent` (boolean, optional) — если `true`, не отправлять ответ. + +## Надёжность + +- При `failed=1` обновляй `ts`. +- При `failed=2|3` запрашивай новый `server/key/ts` через `groups.getLongPollServer`. +- Игнорируй пустые/служебные сообщения. +- Не отвечай на исходящие (`out=1`). diff --git a/skills/vk-bridge/references/.env.example b/skills/vk-bridge/references/.env.example new file mode 100644 index 0000000..0e35750 --- /dev/null +++ b/skills/vk-bridge/references/.env.example @@ -0,0 +1,12 @@ +# VK +VK_GROUP_ID=24068391 +VK_TOKEN=vk1.a.your_group_token_here +VK_API_VERSION=5.199 +VK_WAIT=25 + +# Local bridge endpoint (your OpenClaw-side HTTP handler) +OPENCLAW_BRIDGE_URL=http://127.0.0.1:8787/vk/inbound +OPENCLAW_BRIDGE_TIMEOUT_MS=45000 + +# Optional +LOG_LEVEL=info diff --git a/skills/vk-bridge/scripts/vk-longpoll-bridge.mjs b/skills/vk-bridge/scripts/vk-longpoll-bridge.mjs new file mode 100755 index 0000000..3183792 --- /dev/null +++ b/skills/vk-bridge/scripts/vk-longpoll-bridge.mjs @@ -0,0 +1,179 @@ +#!/usr/bin/env node +import fs from 'node:fs'; +import { setTimeout as sleep } from 'node:timers/promises'; + +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 = { + groupId: Number(process.env.VK_GROUP_ID || 0), + token: process.env.VK_TOKEN || '', + apiVersion: process.env.VK_API_VERSION || '5.199', + wait: Number(process.env.VK_WAIT || 25), + bridgeUrl: process.env.OPENCLAW_BRIDGE_URL || '', + bridgeTimeout: Number(process.env.OPENCLAW_BRIDGE_TIMEOUT_MS || 45000), +}; + +if (!CFG.groupId || !CFG.token || !CFG.bridgeUrl) { + console.error('Missing required env: VK_GROUP_ID, VK_TOKEN, OPENCLAW_BRIDGE_URL'); + process.exit(1); +} + +const seen = new Set(); + +function vkApi(method, params = {}) { + const qs = new URLSearchParams({ + ...Object.fromEntries(Object.entries(params).map(([k, v]) => [k, String(v)])), + access_token: CFG.token, + v: CFG.apiVersion, + }); + return fetch(`https://api.vk.com/method/${method}?${qs}`).then(r => r.json()); +} + +async function getLongPollServer() { + const data = await vkApi('groups.getLongPollServer', { group_id: CFG.groupId }); + if (data.error) throw new Error(`groups.getLongPollServer: ${JSON.stringify(data.error)}`); + return data.response; +} + +async function callBridge(evt) { + const msg = evt?.object?.message; + if (!msg) return { silent: true }; + + const body = { + source: 'vk', + group_id: CFG.groupId, + user_id: msg.from_id, + peer_id: msg.peer_id, + text: msg.text || '', + payload: msg.payload || null, + attachments: msg.attachments || [], + raw: evt, + }; + + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), CFG.bridgeTimeout); + try { + const res = await fetch(CFG.bridgeUrl, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + signal: ctrl.signal, + }); + if (!res.ok) throw new Error(`Bridge HTTP ${res.status}`); + return await res.json(); + } finally { + clearTimeout(t); + } +} + +async function sendVk(peerId, text) { + const randomId = Math.floor(Math.random() * 2_147_483_647); + const data = await vkApi('messages.send', { + peer_id: peerId, + random_id: randomId, + message: text, + }); + if (data.error) throw new Error(`messages.send: ${JSON.stringify(data.error)}`); + return data.response; +} + +function isInboundUserMessage(evt) { + if (evt?.type !== 'message_new') return false; + const msg = evt?.object?.message; + if (!msg) return false; + if ((msg.out ?? 0) === 1) return false; + if (!msg.from_id || !msg.peer_id) return false; + return true; +} + +async function main() { + let lp = await getLongPollServer(); + let ts = lp.ts; + + console.log(`[vk-bridge] started: group=${CFG.groupId}`); + + while (true) { + try { + const qs = new URLSearchParams({ + act: 'a_check', + key: lp.key, + ts: String(ts), + wait: String(CFG.wait), + }); + const r = await fetch(`${lp.server}?${qs}`); + const data = await r.json(); + + if (data.failed) { + if (data.failed === 1 && data.ts) { + ts = data.ts; + continue; + } + lp = await getLongPollServer(); + ts = lp.ts; + continue; + } + + ts = data.ts; + for (const evt of data.updates || []) { + if (!isInboundUserMessage(evt)) continue; + + const msg = evt.object.message; + const dedupKey = `${msg.peer_id}:${msg.conversation_message_id || msg.id}`; + if (seen.has(dedupKey)) continue; + seen.add(dedupKey); + if (seen.size > 5000) { + const first = seen.values().next().value; + if (first) seen.delete(first); + } + + try { + const bridged = await callBridge(evt); + if (!bridged || bridged.silent) continue; + const reply = String(bridged.reply || '').trim(); + if (!reply) continue; + await sendVk(msg.peer_id, reply); + } catch (err) { + console.error('[vk-bridge] handler error:', err.message); + } + } + } catch (err) { + console.error('[vk-bridge] loop error:', err.message); + await sleep(1500); + try { + lp = await getLongPollServer(); + ts = lp.ts; + } catch { + await sleep(3000); + } + } + } +} + +main().catch((e) => { + console.error('[vk-bridge] fatal:', e); + process.exit(1); +});