accessToken = $accessToken; } /** * Make API request to VK * * @param string $method API method * @param array $params Parameters * @return array Response */ private function request($method, $params = []) { $params['access_token'] = $this->accessToken; $params['v'] = $this->apiVersion; $url = $this->baseUrl . $method; $ch = curl_init(); curl_setopt_array($ch, [ CURLOPT_URL => $url, CURLOPT_POST => true, CURLOPT_POSTFIELDS => http_build_query($params), CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 60, CURLOPT_SSL_VERIFYPEER => true, ]); $response = curl_exec($ch); $error = curl_error($ch); curl_close($ch); if ($error) { throw new RuntimeException("VK API connection error: {$error}"); } $data = json_decode($response, true); if (isset($data['error'])) { $errorMsg = isset($data['error']['error_msg']) ? $data['error']['error_msg'] : 'Unknown error'; $errorCode = isset($data['error']['error_code']) ? $data['error']['error_code'] : 0; throw new RuntimeException("VK API error [{$errorCode}]: {$errorMsg}"); } return isset($data['response']) ? $data['response'] : []; } /** * Get current user info * * @return array User info */ public function getMe() { $result = $this->request('users.get', [ 'fields' => 'photo_100,screen_name' ]); if (!empty($result[0])) { $this->userId = $result[0]['id']; return $result[0]; } return []; } /** * Get groups where user can post * * @param int $count Number of groups to return * @return array Groups list */ public function getGroups($count = 100) { $result = $this->request('groups.get', [ 'extended' => 1, 'filter' => 'admin,editor,moder', 'fields' => 'name,screen_name,photo_100,can_post', 'count' => $count ]); $groups = []; if (isset($result['items'])) { foreach ($result['items'] as $group) { // Only groups where posting is allowed if (!empty($group['can_post']) || isset($group['admin_level'])) { $groups[] = [ 'id' => '-' . $group['id'], // Negative for group wall 'name' => $group['name'], 'screen_name' => isset($group['screen_name']) ? $group['screen_name'] : '', 'photo' => isset($group['photo_100']) ? $group['photo_100'] : '', ]; } } } return $groups; } /** * Upload photo to VK from URL * * @param int $groupId Group ID (without minus, positive number) * @param string $photoUrl Photo URL to upload * @return string Attachment string (photo{owner}_{id}) */ public function uploadPhotoFromUrl($groupId, $photoUrl) { $groupId = abs((int)$groupId); // Get upload server try { $uploadServer = $this->request('photos.getWallUploadServer', [ 'group_id' => $groupId ]); } catch (Exception $e) { throw new RuntimeException('Не удалось получить сервер загрузки VK: ' . $e->getMessage()); } if (!isset($uploadServer['upload_url'])) { throw new RuntimeException('VK не вернул URL для загрузки фото'); } // Download photo from URL with context for HTTPS $context = stream_context_create([ 'http' => [ 'timeout' => 30, 'user_agent' => 'VH-Posting-System/1.0' ], 'ssl' => [ 'verify_peer' => true, 'verify_peer_name' => true ] ]); $photoData = @file_get_contents($photoUrl, false, $context); if ($photoData === false) { throw new RuntimeException('Не удалось скачать фото с Flickr: ' . $photoUrl); } // Detect mime type $finfo = new finfo(FILEINFO_MIME_TYPE); $mimeType = $finfo->buffer($photoData) ?: 'image/jpeg'; $extension = $mimeType === 'image/png' ? 'png' : 'jpg'; // Save to temp file $tempFile = tempnam(sys_get_temp_dir(), 'vk_photo_'); file_put_contents($tempFile, $photoData); // Upload photo using CURLFile $ch = curl_init(); curl_setopt_array($ch, [ CURLOPT_URL => $uploadServer['upload_url'], CURLOPT_POST => true, CURLOPT_POSTFIELDS => [ 'photo' => new CURLFile($tempFile, $mimeType, 'photo.' . $extension) ], CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 120, ]); $uploadResponse = curl_exec($ch); $curlError = curl_error($ch); curl_close($ch); unlink($tempFile); if ($curlError) { throw new RuntimeException('Ошибка загрузки фото на VK: ' . $curlError); } $uploadData = json_decode($uploadResponse, true); if (!$uploadData) { throw new RuntimeException('Неверный ответ от сервера VK при загрузке'); } if (empty($uploadData['photo']) || $uploadData['photo'] === '[]') { $errorMsg = isset($uploadData['error']) ? $uploadData['error'] : 'пустой ответ'; throw new RuntimeException('VK не принял фото: ' . $errorMsg); } // Save photo try { $savedPhoto = $this->request('photos.saveWallPhoto', [ 'group_id' => $groupId, 'photo' => $uploadData['photo'], 'server' => $uploadData['server'], 'hash' => $uploadData['hash'] ]); } catch (Exception $e) { throw new RuntimeException('Не удалось сохранить фото в VK: ' . $e->getMessage()); } if (empty($savedPhoto[0])) { throw new RuntimeException('VK не вернул данные сохранённого фото'); } $photo = $savedPhoto[0]; return 'photo' . $photo['owner_id'] . '_' . $photo['id']; } /** * Post to wall/group * * @param int $ownerId User ID or Group ID (negative for groups) * @param string $message Post text * @param array $attachments Array of attachments * @param bool $fromGroup Post from group name (only for groups) * @return array Post info */ public function wallPost($ownerId, $message = '', $attachments = [], $fromGroup = true) { $params = [ 'owner_id' => $ownerId, 'message' => $message, ]; if (!empty($attachments)) { $params['attachments'] = implode(',', $attachments); $params['primary_attachments_mode'] = 'grid'; } // If posting to group, post from group name if ($ownerId < 0 && $fromGroup) { $params['from_group'] = 1; } return $this->request('wall.post', $params); } /** * Post photos and text to a group * * @param int $groupId Group ID (will be converted to negative) * @param array $photoUrls Array of photo URLs * @param string $message Post text * @return array Result */ public function post($groupId, $photoUrls = [], $message = '') { $attachments = []; $uploadErrors = []; $permissionError = false; // Make sure group ID is numeric $numericGroupId = (int)$groupId; if ($numericGroupId > 0) { $numericGroupId = -$numericGroupId; } // Try to upload each photo foreach ($photoUrls as $url) { try { $attachments[] = $this->uploadPhotoFromUrl(abs($numericGroupId), $url); } catch (Exception $e) { $errorMsg = $e->getMessage(); $uploadErrors[] = $errorMsg; error_log("VK photo upload failed: " . $errorMsg); // Check if it's a permission error (error 15 = Access denied, error 27 = group auth) if (strpos($errorMsg, 'error [15]') !== false || strpos($errorMsg, 'error [27]') !== false || strpos($errorMsg, 'Access denied') !== false || strpos($errorMsg, 'group auth') !== false) { $permissionError = true; break; // Don't try other photos if it's a permission issue } } } // If permission error, try to post with photo links in text if ($permissionError && !empty($photoUrls)) { $photoLinks = "\n\n📷 Фото:\n" . implode("\n", $photoUrls); $messageWithPhotos = $message . $photoLinks; try { $result = $this->wallPost($numericGroupId, $messageWithPhotos, []); $firstError = $uploadErrors[0] ?? 'неизвестная ошибка'; $result['warning'] = 'Фото добавлены как ссылки — VK отказал в загрузке (' . $firstError . '). ' . 'Проверьте, что ключ сообщества создан с правом «Фотографии» и принадлежит ИМЕННО той группе, в которую вы постите. ' . 'Если используете пользовательский токен — у него должны быть права wall, photos, groups.'; return $result; } catch (Exception $e) { throw new RuntimeException('Ошибка постинга: ' . $e->getMessage() . '. Также нет прав на загрузку фото.'); } } // If all photos failed to upload for non-permission reasons, report the first error if (!empty($photoUrls) && empty($attachments) && !empty($uploadErrors)) { throw new RuntimeException('Ошибка загрузки фото: ' . $uploadErrors[0]); } return $this->wallPost($numericGroupId, $message, $attachments); } /** * Post to multiple groups at once * * @param array $groupIds Array of group IDs * @param array $photoUrls Array of photo URLs * @param string $message Post text * @return array Results for each group */ public function postToMultiple($groupIds, $photoUrls = [], $message = '') { $results = []; foreach ($groupIds as $groupId) { try { $results[$groupId] = [ 'success' => true, 'result' => $this->post($groupId, $photoUrls, $message), ]; } catch (Exception $e) { $results[$groupId] = [ 'success' => false, 'error' => $e->getMessage(), ]; } // VK rate limit: max 3 requests per second usleep(350000); } return $results; } /** * Validate access token (supports both user and community tokens) * * @return array Validation result */ public function validateToken() { // First try user token validation try { $user = $this->getMe(); if (!empty($user)) { return [ 'valid' => true, 'type' => 'user', 'user_id' => $user['id'], 'user_name' => trim(($user['first_name'] ?? '') . ' ' . ($user['last_name'] ?? '')), 'screen_name' => $user['screen_name'] ?? '', ]; } } catch (Exception $e) { // User token failed, try community token } // Try community token validation using groups.getById with group_id from token try { // For community tokens, we can get group info using groups.getById // The token should have access to its own group $result = $this->request('groups.getById', [ 'fields' => 'name,screen_name,photo_100' ]); if (!empty($result['groups'][0])) { $group = $result['groups'][0]; return [ 'valid' => true, 'type' => 'community', 'user_id' => '-' . $group['id'], 'user_name' => $group['name'] ?? 'Сообщество', 'screen_name' => $group['screen_name'] ?? '', ]; } // VK API v5.199+ returns in different format if (!empty($result[0])) { $group = $result[0]; return [ 'valid' => true, 'type' => 'community', 'user_id' => '-' . $group['id'], 'user_name' => $group['name'] ?? 'Сообщество', 'screen_name' => $group['screen_name'] ?? '', ]; } } catch (Exception $e) { return [ 'valid' => false, 'error' => $e->getMessage(), ]; } return ['valid' => false, 'error' => 'Invalid token']; } }