Не хотите разбираться в коде?

Всю инструкцию ниже можно вообще не читать. Просто скопируйте и отправьте боту:

Прочитай инструкцию по ссылке:
https://bbb2.ru/product/avtoposting-vk-telegram

Настрой мне свой автопостинг в VK и Telegram как там написано.
Я подготовлю токены и данные (VK, Telegram, BotFather) — спрашивай по ходу дела.

Бот откроет ссылку, прочитает всё и начнёт спрашивать по шагам. Код, PHP, Node.js — вообще не нужны. Вы просто отвечаете на вопросы. В конце получите работающий автопостинг.

Что подготовить перед началом

Что нужноГде взять / как сделать
VK access_tokenVK ID Настройки API Создать ключ (отметить wall + photos + groups)
VK group_idСообщество Управление Адрес (это число, например 239867540)
Telegram Bot Token@BotFather /newbot получите токен вида 12345:ABCdef...
Telegram КаналСоздайте канал, добавьте бота администратором (обязательно через приложение, не через Web)
Настройки BotFatherAllow Groups? = ON, Group Privacy = OFF
PHP 7.4+ с curlУже есть на любом нормальном хостинге
Node.js 18+Нужен для воркера Telegram (даже на VPS с PHP)
npm-пакет undicinpm install undici (для обхода блокировки). Если у вас уже стоит OpenClaw — undici есть в его node_modules, npm install не нужен
CronДолжен быть доступен (на VPS есть всегда)
Важно: если ваш сервер физически находится в России — понадобится обход блокировки Telegram API. Решение есть ниже в разделе «Обход блокировки без прокси». Если сервер за границей — этот раздел можно пропустить.

Зачем я сделал свой автопостинг

Я веду сайт bbb2.ru, пишу статьи и хочу чтобы они автоматом уходили в VK и Telegram. Сервисов для этого — десятки: SMMplanner, Amplifer, Telepost. Но все они чего-то хотят: денег, доступов, подписок.

Я сел и написал свой автопостинг. Заняло вечер. Теперь он работает прямо из админки сайта — одна кнопка, пост улетает в обе соцсети. Код ниже — ровно то, что работает у меня. Вы можете взять его целиком, показать боту, и он сделает то же самое для вашего сервера.

Кстати, я всё это делал через чат с ботом — не писал код руками, а просто объяснял что нужно. Ниже под каждым блоком кода я покажу что именно я отправлял боту, чтобы вы могли сделать так же.

Как это работает — на пальцах

У меня PHP-админка на сайте. Я захожу в раздел «Автопостинг», пишу текст, опционально прикрепляю картинку — и жму одну из трёх кнопок:

  • ВК — пост уходит мгновенно
  • Telegram — пост попадает в очередь и уходит в течение 2 минут
  • Везде — понятно :)

Под капотом — класс SocialPoster с двумя публичными методами. VK — прямой HTTP-запрос к API ВКонтакте. Telegram — запись в очередь на диске (/tmp/tg_post_queue/), которую разбирает отдельный Node.js-воркер.

Telegram через очередь а не напрямую? Скоро расскажу — там самое интересное.

Часть 1. VK — удивительно просто

Запрос боту в чат:

Настрой мне автопостинг в VK — ключ доступа, ID группы и всё остальное

Что вам понадобится

ЧтоГде взять
access_tokenVK ID Настройки API Создать ключ
group_idСообщество Управление Адрес (это число, не короткое имя)
Важный момент: когда создаёте токен в VK ID, вручную отметьте три галочки: wall, photos и groups. По умолчанию они не отмечены — и пост с картинкой молча упадёт. Я на этом споткнулся в первый раз.

Текстовый пост — один запрос

Для обычного поста без картинки хватает одного вызова:

Запрос боту в чат:

Напиши мне метод для отправки текстового поста в VK через API с токенами из .env
function postToVkText(string $message): array {
    $token = getenv('VK_ACCESS_TOKEN');
    $groupId = getenv('VK_GROUP_ID');

    $params = http_build_query([
        'owner_id'   => '-' . $groupId,  // минус — это группа
        'message'    => $message,
        'access_token' => $token,
        'v'          => '5.199',
    ]);

    $url = "https://api.vk.com/method/wall.post?{$params}";
    $response = @file_get_contents($url);

    if ($response === false) {
        return ['success' => false, 'error' => 'VK API не ответил'];
    }

    $data = json_decode($response, true);
    if (isset($data['error'])) {
        return ['success' => false, 'error' => $data['error']['error_msg']];
    }

    return ['success' => true, 'post_id' => $data['response']['post_id']];
}

Обратите внимание: owner_id группы передаётся с минусом (например -239867540). Без минуса VK думает что вы пишете пользователю, а не в группу. И file_get_contents может вернуть false при проблемах с сетью — всегда проверяйте.

Пост с картинкой — четыре шага в одной функции

В отличие от Telegram, VK не умеет принимать фото и текст в одном запросе. Приходится идти по цепочке из четырёх шагов. Вот полный метод:

Запрос боту в чат:

Напиши мне метод для поста с картинкой в VK (загрузка фото + сохранение + прикрепление)
function postToVkWithPhoto(string $message, string $imagePath): array {
    $token = getenv('VK_ACCESS_TOKEN');
    $groupId = getenv('VK_GROUP_ID');

    if (!file_exists($imagePath)) {
        return ['success' => false, 'error' => 'Файл изображения не найден'];
    }

    // Шаг 1: получаем адрес для загрузки фото
    $uploadServer = @file_get_contents(
        "https://api.vk.com/method/photos.getWallUploadServer?" .
        http_build_query([
            'group_id' => $groupId,
            'access_token' => $token,
            'v' => '5.199',
        ])
    );
    if ($uploadServer === false) {
        return ['success' => false, 'error' => 'Не удалось получить upload server'];
    }

    $uploadData = json_decode($uploadServer, true);
    if (isset($uploadData['error'])) {
        return ['success' => false, 'error' => $uploadData['error']['error_msg']];
    }
    $uploadUrl = $uploadData['response']['upload_url'];

    // Шаг 2: загружаем фото на этот адрес (через curl с CURLFile)
    $ch = curl_init();
    curl_setopt_array($ch, [
        CURLOPT_URL            => $uploadUrl,
        CURLOPT_POST           => true,
        CURLOPT_POSTFIELDS     => ['photo' => new CURLFile($imagePath)],
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT        => 30,
    ]);
    $photoResponse = curl_exec($ch);
    $curlError = curl_error($ch);
    curl_close($ch);

    if ($photoResponse === false) {
        return ['success' => false, 'error' => "Загрузка фото: {$curlError}"];
    }

    $photoData = json_decode($photoResponse, true);
    if (!isset($photoData['photo'])) {
        return ['success' => false, 'error' => 'VK не принял фото'];
    }

    // Шаг 3: сохраняем фото в альбоме группы
    $saveResult = @file_get_contents(
        "https://api.vk.com/method/photos.saveWallPhoto?" .
        http_build_query([
            'group_id'     => $groupId,
            'photo'        => $photoData['photo'],
            'server'       => $photoData['server'],
            'hash'         => $photoData['hash'],
            'access_token' => $token,
            'v'            => '5.199',
        ])
    );
    if ($saveResult === false) {
        return ['success' => false, 'error' => 'Не удалось сохранить фото'];
    }

    $savedData = json_decode($saveResult, true);
    if (isset($savedData['error'])) {
        return ['success' => false, 'error' => $savedData['error']['error_msg']];
    }
    $savedPhoto = $savedData['response'][0];

    // Шаг 4: публикуем пост с прикреплённым фото
    $attachment = "photo{$savedPhoto['owner_id']}_{$savedPhoto['id']}";

    $postResult = @file_get_contents(
        "https://api.vk.com/method/wall.post?" .
        http_build_query([
            'owner_id'     => '-' . $groupId,
            'message'      => $message,
            'attachment'   => $attachment,
            'access_token' => $token,
            'v'            => '5.199',
        ])
    );
    if ($postResult === false) {
        return ['success' => false, 'error' => 'Пост не опубликован (сетевая ошибка)'];
    }

    $postData = json_decode($postResult, true);
    if (isset($postData['error'])) {
        return ['success' => false, 'error' => $postData['error']['error_msg']];
    }

    return ['success' => true, 'post_id' => $postData['response']['post_id']];
}

Четыре запроса на один пост с картинкой — так устроен VK API. Зато для текстового хватает одного. Каждый шаг проверяет ошибки — без этого пост просто молча не выйдет, а вы не узнаете почему.

Часть 2. Telegram — вот где началось приключение

Запрос боту в чат:

Настрой мне автопостинг в Telegram — бот, канал, очередь и воркер

Что подготовить

ЧтоГде взять
Bot token@BotFather в Telegram /newbot получите токен вида 12345:ABCdef...
КаналСоздайте канал в Telegram (например @my_channel)
Бот — админ каналаКанал Администраторы Добавить @имя_вашего_бота
Я убил на это час: добавить бота администратором канала через Telegram Web (web.telegram.org) — невозможно. Веб-версия просто не показывает секцию с администраторами канала. Нужно делать через мобильное приложение (Android или iOS) либо Telegram Desktop. Я всерьёз пытался найти кнопку, которой не существует.

Настройки бота в BotFather

После создания бота зайдите в @BotFather /mybots ваш бот Bot Settings и проверьте два параметра:

  • Allow Groups?должен быть ON (иначе бот не сможет писать в канал)
  • Group Privacyдолжен быть OFF (бот должен видеть сообщения, а не только команды)
Ещё один подводный камень: если Allow Groups? выключен, бот молча игнорирует попытки отправить сообщение в канал. Не падает с ошибкой — просто не доставляет. Проверьте эту настройку до того, как начнёте писать код.

Прокси для Telegram: MTProto, SOCKS5 и почему это не одно и то же

Если ваш сервер в России — этот раздел для вас. Если сервер за границей — можете смело пропустить.

В чём проблема

Telegram Bot API находится по адресу api.telegram.org. РКН блокирует этот домен (и его IP-адреса) для российских серверов. Прямой запрос через curl с российского VPS просто зависает — таймаут через 30 секунд.

Три вида прокси — и почему два не подходят

Тип проксиЧто этоРаботает с Bot API?Почему
SOCKS5TCP-туннель, пропускает любой трафик Да, но не каждыйНекоторые SOCKS5-провайдеры (например px6.net) блокируют IP-адреса Telegram на уровне назначения. Прокси работает для обычных сайтов, но не для Telegram
HTTP/HTTPSПрокси с методом CONNECT Даcurl работает «из коробки», если прокси не режет Telegram
MTProtoБинарный протокол клиента Telegram
✗
Нет
MTProto — это протокол приложения Telegram на телефоне. Bot API работает через HTTPS REST. Это два совершенно разных протокола. Физически несовместимы.
Самое важное про MTProto:

В интернете полно статей «прокси для Telegram», и 99% из них — про MTProto. Ссылки вида tg://proxy?server=...&port=.... Эти прокси созданы чтобы приложение Telegram на вашем телефоне обходило блокировку. Они не передают HTTPS.

Я проверил лично: curl через MTProto-прокси до api.telegram.org не доходит — таймаут. Вывод: для бота ищите SOCKS5 или HTTP-прокси, который не блокирует Telegram на уровне IP. Либо — читайте дальше, я покажу как обойтись вообще без прокси.

Как быстро проверить прокси

Запрос боту в чат:

Проверь, работает ли мой SOCKS5-прокси для Telegram API
# Через SOCKS5
curl --socks5-hostname proxy_ip:port \\
     --proxy-user user:pass \\
     -I "https://api.telegram.org" \\
     --connect-timeout 5 --max-time 10

# Через HTTP
curl --proxy http://proxy_ip:port \\
     -I "https://api.telegram.org" \\
     --connect-timeout 5 --max-time 10

Если ответ пришёл (хоть 200, хоть 404) — прокси работает. Если тишина и таймаут — прокси блокирует Telegram.

Обход блокировки без прокси: фиксированные IP

Пока я тестировал прокси, бот предложил решение которое не требует внешнего прокси вообще.

Как это работает

РКН блокирует api.telegram.org на уровне DNS (определении IP-адреса сайта) и маршрутизации, но не все IP-адреса Telegram API блокируются одинаково. Я использую трёхслойный подход:

  1. Только IPv4 — принудительно запрашиваем только IPv4-адреса (IPv6 на российских серверах часто ведёт себя непредсказуемо)
  2. Резервный IP — если стандартный DNS не помог, идём напрямую на 149.154.167.220 (один из серверов Telegram API, который стабильно отвечает с российских IP)
  3. Запоминание — если резервный IP сработал, используем его для всех последующих запросов

Код на Node.js — функция для обхода блокировки

Запрос боту в чат:

Напиши мне Node.js скрипт для обхода блокировки Telegram через undici с резервными IP (fallback на 149.154.167.220)
const { Agent, fetch } = require('undici');
const { lookup } = require('node:dns');

const FALLBACK_IPS = ['149.154.167.220'];

// Только IPv4 — избегаем проблем с IPv6 на российских серверах
function ipv4Lookup(hostname, options, callback) {
  return lookup(hostname, { ...(options || {}), family: 4 }, callback);
}

async function telegramFetch(url, init) {
  // Попытка 1: свой DNS, только IPv4
  try {
    const agent = new Agent({
      connect: { lookup: ipv4Lookup, autoSelectFamily: false }
    });
    return await fetch(url, { ...init, dispatcher: agent });
  } catch (e) { /* идём дальше */ }

  // Попытка 2+: резервные IP напрямую (в обход DNS)
  for (const ip of FALLBACK_IPS) {
    try {
      const agent = new Agent({
        connect: {
          lookup: (h, opts, cb) => cb(null, [{ address: ip, family: 4 }])
        }
      });
      return await fetch(url, {
        ...init,
        dispatcher: agent,
        headers: { ...init?.headers, Host: 'api.telegram.org' }
      });
    } catch (e) { /* пробуем следующий IP */ }
  }

  throw new Error('Все попытки подключения к Telegram не удались');
}

Этот подход работает прямо сейчас с российского сервера. Никакого внешнего прокси. Зависимость одна — npm-пакет undici.

Важно про версии undici: API библиотеки меняется между мажорными версиями. При обновлении с undici 7.x на 8.x метод setGlobalDispatcher() был удалён, а интерфейс Dispatcher изменился — старый код с кастомными диспетчерами молча падает с ошибкой invalid onRequestStart method. Код выше написан для undici 7+ и проверен на undici 8.x из OpenClaw 2026.6.10. Если обновляете undici — сверяйтесь с её changelog, паттерны работы с Agent могут измениться.

Почему я сделал очередь для Telegram

Я не вызываю Telegram API напрямую из PHP. Вместо этого SocialPoster::postToTelegram() пишет JSON-файл в папку на диске:

Запрос боту в чат:

Напиши мне PHP-код для записи постов в очередь /tmp/tg_post_queue/ (мгновенно, без ожидания ответа Telegram)
// PHP: запись в очередь — мгновенно (не ждём ответа Telegram)
$queueDir = '/tmp/tg_post_queue';
$filename = "post_" . time() . "_" . bin2hex(random_bytes(2)) . ".json";

file_put_contents("{$queueDir}/{$filename}", json_encode([
    'channel'   => '@my_channel',
    'token'     => $botToken,
    'message'   => $message,
    'image'     => $imageCopyPath,  // или null
    'timestamp' => time(),
]));

А отдельный Node.js-воркер раз в 2 минуты (через крон) подбирает файлы из очереди и отправляет. Вот полный код воркера — tg_queue_worker.js:

Запрос боту в чат:

Напиши мне полный tg_queue_worker.js: чтение очереди из /tmp/tg_post_queue/, отправка через undici с fallback IP, удаление старых файлов
#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
const { Agent, fetch } = require('undici');
const { lookup } = require('node:dns');

const QUEUE_DIR = '/tmp/tg_post_queue';
const FALLBACK_IPS = ['149.154.167.220'];

// --- Telegram Fetch с обходом блокировки ---

function ipv4Lookup(hostname, options, callback) {
    return lookup(hostname, { ...(options || {}), family: 4 }, callback);
}

async function telegramFetch(url, init) {
    try {
        const agent = new Agent({
            connect: { lookup: ipv4Lookup, autoSelectFamily: false }
        });
        return await fetch(url, { ...init, dispatcher: agent });
    } catch (e) {}

    for (const ip of FALLBACK_IPS) {
        try {
            const agent = new Agent({
                connect: {
                    lookup: (h, opts, cb) => cb(null, [{ address: ip, family: 4 }])
                }
            });
            return await fetch(url, {
                ...init,
                dispatcher: agent,
                headers: { ...init?.headers, Host: 'api.telegram.org' }
            });
        } catch (e) {}
    }

    throw new Error('Не удалось подключиться к Telegram API');
}

// --- Отправка одного поста ---

async function sendMessage(token, channel, message) {
    const url = `https://api.telegram.org/bot${token}/sendMessage`;
    const body = JSON.stringify({
        chat_id: channel,
        text: message,
        parse_mode: 'HTML',
    });

    const resp = await telegramFetch(url, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: body,
    });

    const data = await resp.json();
    if (!data.ok) {
        throw new Error(`Telegram API: ${data.description}`);
    }
    return data;
}

async function sendPhoto(token, channel, message, imagePath) {
    const url = `https://api.telegram.org/bot${token}/sendPhoto`;

    const FormData = require('undici').FormData;
    const form = new FormData();
    form.append('chat_id', channel);
    form.append('caption', message);
    form.append('parse_mode', 'HTML');
    form.append('photo', fs.createReadStream(imagePath));

    const resp = await telegramFetch(url, {
        method: 'POST',
        body: form,
        // Не ставим Content-Type — undici сам выставит multipart/form-data с boundary
    });

    const data = await resp.json();
    if (!data.ok) {
        throw new Error(`Telegram API (photo): ${data.description}`);
    }
    return data;
}

// --- Обработка очереди ---

async function processQueue() {
    if (!fs.existsSync(QUEUE_DIR)) {
        console.log(`[${new Date().toISOString()}] Очередь не найдена: ${QUEUE_DIR}`);
        return;
    }

    const files = fs.readdirSync(QUEUE_DIR)
        .filter(f => f.startsWith('post_') && f.endsWith('.json'))
        .sort(); // Старые первыми

    if (files.length === 0) return;

    console.log(`[${new Date().toISOString()}] Постов в очереди: ${files.length}`);

    for (const file of files) {
        const filePath = path.join(QUEUE_DIR, file);
        let payload;

        try {
            payload = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
        } catch (e) {
            console.error(`⚠️  ${file}: невалидный JSON, удаляю`);
            fs.unlinkSync(filePath);
            continue;
        }

        const { token, channel, message, image, timestamp } = payload;

        try {
            if (image && fs.existsSync(image)) {
                await sendPhoto(token, channel, message, image);
                // Удаляем временную копию картинки
                fs.unlinkSync(image);
            } else {
                await sendMessage(token, channel, message);
            }

            console.log(`✅ ${file}: отправлен (${message.substring(0, 50)}...)`);

            // Успех — удаляем файл из очереди
            fs.unlinkSync(filePath);
        } catch (e) {
            console.error(`❌ ${file}: ${e.message}`);

            // Если файл старше 1 часа — удаляем как безнадёжный
            const ageSeconds = (Date.now() / 1000) - (timestamp || 0);
            if (ageSeconds > 3600) {
                console.error(`⏰ ${file}: старше 1 часа, удаляю`);
                fs.unlinkSync(filePath);
                if (image && fs.existsSync(image)) fs.unlinkSync(image);
            }
            // Иначе оставляем — попробуем снова через 2 минуты
        }
    }
}

// Запуск
processQueue().catch(e => {
    console.error(`Критическая ошибка воркера: ${e.message}`);
    process.exit(1);
});

Крон — каждые 2 минуты:

Запрос боту в чат:

Добавь запись в crontab: запуск tg_queue_worker.js каждые 2 минуты
*/2 * * * * node /usr/local/bin/tg_queue_worker.js >> /var/log/tg_queue.log 2>&1

Четыре причины, почему так а не напрямую

  1. Админка не тормозит. Прямой запрос к Telegram может занять 10-30 секунд. Вы нажали кнопку и пошли дальше — пост уйдёт сам.
  2. Устойчивость к сбоям. Если Telegram временно недоступен — файл остаётся в очереди. Воркер попробует снова через 2 минуты.
  3. Механизм с fallback IP. Он реализован на Node.js (через undici). Сделать то же самое на голом PHP с curl сложнее и менее надёжно.
  4. Готовые логи. Каждый файл в очереди — это запись: что, когда и куда отправили. Не нужно отдельного логирования.

Полный код класса SocialPoster

Запрос боту в чат:

Сделай мне класс SocialPoster для автопостинга в VK и Telegram с токенами из .env

Это итоговый класс, который работает у меня на bbb2.ru. Отправьте боту этот код — он адаптирует его под ваш сервер, подставит токены из .env и проверит что все методы работают.

<?php
class SocialPoster {
    private string $vkToken;
    private string $vkGroupId;
    private string $tgToken;
    private string $tgChannel;
    private string $queueDir;

    public function __construct() {
        $this->vkToken   = getenv('VK_ACCESS_TOKEN');
        $this->vkGroupId = getenv('VK_GROUP_ID');
        $this->tgToken   = getenv('TELEGRAM_BOT_TOKEN');
        $this->tgChannel = getenv('TELEGRAM_CHANNEL');
        $this->queueDir  = '/tmp/tg_post_queue';

        if (!is_dir($this->queueDir)) {
            mkdir($this->queueDir, 0700, true);
        }
    }

    /** Публикация в VK — автоматически выбирает текст или фото */
    public function postToVK(string $message, ?string $imagePath = null): array {
        if ($imagePath && file_exists($imagePath)) {
            return $this->postToVkWithPhoto($message, $imagePath);
        }
        return $this->postToVkText($message);
    }

    /** Публикация в Telegram (через очередь) */
    public function postToTelegram(string $message, ?string $imagePath = null): array {
        $payload = [
            'channel'   => $this->tgChannel,
            'token'     => $this->tgToken,
            'message'   => $message,
            'image'     => null,
            'timestamp' => time(),
        ];

        if ($imagePath && file_exists($imagePath)) {
            $ext = pathinfo($imagePath, PATHINFO_EXTENSION);
            $copyPath = "{$this->queueDir}/img_" . time() . "_" . bin2hex(random_bytes(4)) . ".{$ext}";
            copy($imagePath, $copyPath);
            $payload['image'] = $copyPath;
        }

        $filename = "{$this->queueDir}/post_" . time() . "_" . bin2hex(random_bytes(2)) . ".json";
        $written = file_put_contents($filename, json_encode($payload, JSON_UNESCAPED_UNICODE));

        return [
            'success' => (bool)$written,
            'method'  => 'queue',
            'file'    => basename($filename),
        ];
    }

    // (методы postToVkText и postToVkWithPhoto — см. выше в статье)
}

Страница автопостинга в админке

Минимальный пример страницы /admin/social.php — форма с textarea, полем загрузки картинки и тремя кнопками:

Запрос боту в чат:

Напиши мне admin/social.php — страницу автопостинга с формой (textarea, загрузка картинки, кнопки VK/Telegram/Всё вместе)
<?php
require_once __DIR__ . '/../includes/api/SocialPoster.php';

$poster = new SocialPoster();
$message = '';
$result = null;

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $message = $_POST['message'] ?? '';
    $imagePath = null;

    // Если загрузили картинку — сохраняем во временный файл
    if (!empty($_FILES['image']['tmp_name'])) {
        $ext = pathinfo($_FILES['image']['name'], PATHINFO_EXTENSION);
        $imagePath = '/tmp/post_img_' . time() . '.' . $ext;
        move_uploaded_file($_FILES['image']['tmp_name'], $imagePath);
    }

    $action = $_POST['action'] ?? '';

    try {
        if ($action === 'vk') {
            $result = $poster->postToVK($message, $imagePath);
        } elseif ($action === 'tg') {
            $result = $poster->postToTelegram($message, $imagePath);
        } elseif ($action === 'both') {
            $result = [
                'vk' => $poster->postToVK($message, $imagePath),
                'tg' => $poster->postToTelegram($message, $imagePath),
            ];
        }
    } finally {
        // Подчищаем временный файл если был
        if ($imagePath && file_exists($imagePath)) {
            unlink($imagePath);
        }
    }
}
?>
<div style="max-width:700px; margin:40px auto;">
    <h1>Автопостинг</h1>

    <?php if ($result): ?>
        <div style="background:#e2efe4; padding:16px 20px; border-radius:10px; margin-bottom:20px;">
            <pre><?= htmlspecialchars(json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)) ?></pre>
        </div>
    <?php endif; ?>

    <form method="post" enctype="multipart/form-data">
        <textarea name="message" rows="6" style="width:100%; padding:12px; font:inherit; border:1px solid #ddd; border-radius:8px;"
                  placeholder="Текст поста..."><?= htmlspecialchars($message) ?></textarea>

        <div style="margin:16px 0;">
            <label style="cursor:pointer; display:inline-block; padding:8px 16px; background:#f0f0f0; border-radius:6px;">
                📎 Прикрепить картинку
                <input type="file" name="image" accept="image/*" style="display:none;">
            </label>
        </div>

        <div style="display:flex; gap:12px;">
            <button type="submit" name="action" value="vk" style="padding:12px 24px; background:#0077FF; color:#fff; border:none; border-radius:8px; font-size:16px; cursor:pointer;">
                📘 ВК
            </button>
            <button type="submit" name="action" value="tg" style="padding:12px 24px; background:#2AABEE; color:#fff; border:none; border-radius:8px; font-size:16px; cursor:pointer;">
                ✈️ Telegram
            </button>
            <button type="submit" name="action" value="both" style="padding:12px 24px; background:#F76230; color:#fff; border:none; border-radius:8px; font-size:16px; cursor:pointer;">
                Везде
            </button>
        </div>
    </form>
</div>

Развёртывание: пошагово

Запрос боту в чат:

Разверни автопостинг на моём сервере — скопируй файлы, установи зависимости, пропиши переменные, настрой крон

1. Скопируйте файлы на сервер

includes/api/SocialPoster.php     ← класс (код выше)
admin/social.php                  ← админ-страница (код выше)
tg_queue_worker.js                ← воркер (кладёте в корень проекта, где .env)

2. Установите зависимости Node.js

cd /ваш/проект
npm install undici
Если у вас OpenClaw: undici уже лежит в /usr/lib/node_modules/openclaw/node_modules/. Отдельный npm install не нужен — просто укажите NODE_PATH в кроне (см. шаг 4).

3. Добавьте переменные в .env

VK_ACCESS_TOKEN=ваш_токен_vk
VK_GROUP_ID=239867540
TELEGRAM_BOT_TOKEN=12345:ABCdef...
TELEGRAM_CHANNEL=@my_channel

4. Настройте крон

crontab -e
# добавьте одну из строк (замените пути на свои):

# Вариант 1: со своим npm install undici
*/2 * * * * /usr/bin/node /путь/к/tg_queue_worker.js >> /var/log/tg_queue.log 2>&1

# Вариант 2: undici из OpenClaw (без отдельного npm install)
*/2 * * * * cd /путь/к/проекту && NODE_PATH=/usr/lib/node_modules/openclaw/node_modules /usr/bin/node tg_queue_worker.js >> /var/log/tg_queue.log 2>&1

5. Проверьте

  • Зайдите в /admin/social.php
  • Напишите тестовый текст
  • Нажмите «ВК» — пост должен появиться мгновенно
  • Нажмите «Telegram» — пост должен прийти в течение 2 минут
  • Проверьте логи: tail -f /var/log/tg_queue.log
Если Telegram не отправляет:
  • Проверьте что node доступен из крона: which node (обычно /usr/bin/node)
  • Проверьте что undici доступен: NODE_PATH=/путь/к/node_modules node -e "require('undici')"
  • Если undici не найден — установите рядом с воркером: cd $(dirname воркер) && npm install undici
  • Проверьте права на запись в /tmp/tg_post_queue/
  • Смотрите логи: tail -f /var/log/tg_queue.log

Частые проблемы и их решения

#СимптомПричинаКак исправить
1 VK-пост с картинкой падает Токен создан без права photos Пересоздать токен в VK ID, отметить wall + photos + groups
2 Не могу добавить бота в админы канала Telegram Web не показывает настройки администраторов Использовать Telegram Desktop или мобильное приложение
3 Бот есть в канале, но посты не доходят Allow Groups? выключен в BotFather @BotFather Bot Settings Allow Groups? = ON
4 curl до api.telegram.org — таймаут Сервер в России, прямое соединение заблокировано Использовать fallback-механизм с резервными IP (см. раздел выше)
5 MTProto-прокси не работает для бота MTProto ≠ HTTPS. Прокси для клиента ≠ прокси для Bot API Искать SOCKS5 или HTTP-прокси. Либо fallback IP — прокси не нужен
6 SOCKS5-прокси работает для сайтов, но не для Telegram Провайдер прокси блокирует IP Telegram API Проверить другой прокси или использовать fallback IP без прокси
7 VK API отвечает «Access denied» owner_id указан без минуса Для группы: owner_id = -group_id

Что получилось в итоге

КомпонентСтатусКак работает
VK — текст МгновенноПрямой вызов wall.post
VK — с фото Мгновенно4 шага: uploadServer загрузка сохранение пост
Telegram — текст До 2 минутОчередь на диске Node.js воркер резервный IP
Telegram — с фото До 2 минутОчередь + копия изображения воркер sendPhoto
Деньги0 ₽Никаких платных сервисов или прокси
Админка ГотовоОдна страница: textarea + загрузка файла + 3 кнопки

Идеи для развития

  • История постов — отдельная таблица: какой пост, когда, в какую соцсеть, статус доставки
  • Отложенный постинг — поле «опубликовать в» с датой и временем
  • Превью — перед отправкой показывать как пост выглядит в каждой соцсети
  • Статистика — подтягивать просмотры и лайки из VK и Telegram
  • Retry для VK — сейчас пост либо прошёл либо упал; добавить пару повторных попыток
За вечер реально поднять автопостинг в обе соцсети с нуля. Я всё это сделал через чат с ботом — просто описывал что нужно и присылал ссылки на документацию. Самое сложное — не код, а навигация по настройкам: BotFather, права токенов, администрирование каналов. Код занимает ~200 строк на PHP и ~100 строк на Node.js для воркера. Ноль внешних сервисов, ноль рублей в месяц. Если не хотите разбираться — просто скопируйте эту статью и отправьте боту, он всё настроит.