From 90bb676fe1fc04d5147eef671086a77150395483 Mon Sep 17 00:00:00 2001 From: OpenClaw Assistant Date: Mon, 23 Feb 2026 08:24:28 +0000 Subject: [PATCH] Add vk-bridge skill with VK Long Poll worker template --- skills/dist/vk-bridge.skill | Bin 0 -> 3605 bytes skills/vk-bridge/SKILL.md | 53 ++++++ skills/vk-bridge/references/.env.example | 12 ++ .../vk-bridge/scripts/vk-longpoll-bridge.mjs | 179 ++++++++++++++++++ 4 files changed, 244 insertions(+) create mode 100644 skills/dist/vk-bridge.skill create mode 100644 skills/vk-bridge/SKILL.md create mode 100644 skills/vk-bridge/references/.env.example create mode 100755 skills/vk-bridge/scripts/vk-longpoll-bridge.mjs diff --git a/skills/dist/vk-bridge.skill b/skills/dist/vk-bridge.skill new file mode 100644 index 0000000000000000000000000000000000000000..72c04cf19987ac36ed704ba3c6ec7aac3b8b0313 GIT binary patch literal 3605 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 literal 0 HcmV?d00001 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); +});