#!/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; } } function parseCsv(value, fallback = []) { if (!value) return fallback; return value .split(',') .map((x) => x.trim()) .filter(Boolean); } 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), allowedEvents: new Set(parseCsv(process.env.VK_ALLOWED_EVENTS, ['message_new'])), syncSettings: process.env.VK_SYNC_LONGPOLL_SETTINGS === '1', antiBotEnabled: process.env.VK_ANTIBOT_ENABLED !== '0', antiBotWindowSec: Number(process.env.VK_ANTIBOT_WINDOW_SEC || 20), antiBotMaxMsgsPerWindow: Number(process.env.VK_ANTIBOT_MAX_MSGS || 4), antiBotBlockSec: Number(process.env.VK_ANTIBOT_BLOCK_SEC || 300), }; if (!CFG.groupId || !CFG.token || !CFG.bridgeUrl) { console.error('Missing required env: VK_GROUP_ID, VK_TOKEN, OPENCLAW_BRIDGE_URL'); process.exit(1); } if (CFG.apiVersion !== '5.199') { console.warn(`[vk-bridge] warning: VK_API_VERSION=${CFG.apiVersion}; recommended 5.199`); } if (CFG.wait > 90 || CFG.wait < 1) { console.error('VK_WAIT must be between 1 and 90'); process.exit(1); } const seen = new Set(); const userTraffic = new Map(); const blockedUsers = new Map(); function nowSec() { return Math.floor(Date.now() / 1000); } 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 getLongPollSettings() { const data = await vkApi('groups.getLongPollSettings', { group_id: CFG.groupId }); if (data.error) throw new Error(`groups.getLongPollSettings: ${JSON.stringify(data.error)}`); return data.response; } async function setLongPollSettings(params) { const data = await vkApi('groups.setLongPollSettings', { group_id: CFG.groupId, ...params, }); if (data.error) throw new Error(`groups.setLongPollSettings: ${JSON.stringify(data.error)}`); return data.response; } async function ensureLongPollSettings() { const settings = await getLongPollSettings(); const updates = { enabled: 1, api_version: CFG.apiVersion }; for (const eventType of CFG.allowedEvents) { const current = settings?.events?.[eventType]; if (current !== 1) updates[eventType] = 1; } const needApply = Object.keys(updates).length > 2 || settings?.is_enabled !== 1; if (!needApply) { console.log('[vk-bridge] long poll settings already OK'); return; } await setLongPollSettings(updates); console.log('[vk-bridge] long poll settings synced'); } function isLikelyBot(msg) { if (!CFG.antiBotEnabled) return false; const uid = msg?.from_id; if (!uid) return false; const now = nowSec(); const blockedUntil = blockedUsers.get(uid) || 0; if (blockedUntil > now) return true; const text = String(msg?.text || '').trim(); const spamByPattern = text.length > 1200 || (text.match(/https?:\/\//gi)?.length || 0) >= 3 || /(крипт|ставк|казино|эрот|18\+|viagra|быстр[ыо]й\s*заработок)/i.test(text); const userState = userTraffic.get(uid) || { times: [] }; userState.times = userState.times.filter((t) => now - t <= CFG.antiBotWindowSec); userState.times.push(now); userTraffic.set(uid, userState); const spamByRate = userState.times.length > CFG.antiBotMaxMsgsPerWindow; if (spamByPattern || spamByRate) { blockedUsers.set(uid, now + CFG.antiBotBlockSec); console.log(`[vk-bridge] antibot block user=${uid} for ${CFG.antiBotBlockSec}s`); return true; } return false; } async function callBridge(evt) { const msg = evt?.object?.message; if (!msg) return { silent: true }; const body = { source: 'vk', group_id: CFG.groupId, event_type: evt?.type || null, event_id: evt?.event_id || null, 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 uploadDocForMessage(peerId, filePath) { const p = String(filePath || '').trim(); if (!p || !fs.existsSync(p)) { throw new Error(`booking doc not found: ${p}`); } const serverRes = await vkApi('docs.getMessagesUploadServer', { peer_id: peerId, type: 'doc' }); if (serverRes.error) throw new Error(`docs.getMessagesUploadServer: ${JSON.stringify(serverRes.error)}`); const uploadUrl = serverRes?.response?.upload_url; if (!uploadUrl) throw new Error('docs.getMessagesUploadServer: upload_url missing'); const form = new FormData(); const buff = fs.readFileSync(p); form.append('file', new Blob([buff]), p.split('/').pop() || 'booking.doc'); const uploadResp = await fetch(uploadUrl, { method: 'POST', body: form }); const uploadJson = await uploadResp.json(); if (!uploadJson?.file) throw new Error(`doc upload failed: ${JSON.stringify(uploadJson)}`); const saveRes = await vkApi('docs.save', { file: uploadJson.file, title: p.split('/').pop() || 'booking.doc' }); if (saveRes.error) throw new Error(`docs.save: ${JSON.stringify(saveRes.error)}`); let doc = null; if (Array.isArray(saveRes?.response)) { doc = saveRes.response[0] || null; } else if (saveRes?.response?.doc) { doc = saveRes.response.doc; } else if (saveRes?.response && saveRes.response.id && saveRes.response.owner_id) { doc = saveRes.response; } if (!doc?.owner_id || !doc?.id) throw new Error(`docs.save bad response: ${JSON.stringify(saveRes.response)}`); return `doc${doc.owner_id}_${doc.id}`; } async function sendVk(peerId, text, attachment = '') { const randomId = Math.floor(Math.random() * 2_147_483_647); const payload = { peer_id: peerId, random_id: randomId, message: text, }; if (attachment) payload.attachment = attachment; const data = await vkApi('messages.send', payload); if (data.error) throw new Error(`messages.send: ${JSON.stringify(data.error)}`); return data.response; } function isInboundUserMessage(evt) { if (!CFG.allowedEvents.has(evt?.type)) return false; 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; if (isLikelyBot(msg)) return false; return true; } async function main() { if (CFG.syncSettings) { try { await ensureLongPollSettings(); } catch (e) { console.error('[vk-bridge] failed to sync long poll settings:', e.message); } } 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; let attachment = ''; if (bridged.send_booking_doc && bridged.booking_doc_path) { try { attachment = await uploadDocForMessage(msg.peer_id, bridged.booking_doc_path); } catch (e) { console.error('[vk-bridge] doc upload error:', e.message); } } await sendVk(msg.peer_id, reply, attachment); } 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); });