Files
VH_posting_system/classes/VKAPI.php
T
zuevav a0dd7b1ed4 Document VK API community-token photo-upload limitation; add Kate Mobile auth flow
VK's photos.getWallUploadServer only accepts user tokens — community tokens fail with error 27 ('Group authorization failed: method is unavailable with group auth') or error 15. The previous note hinted users could just enable the 'photos' right on a community token, which is wrong: the limitation is hard.

- Restore the accurate fallback warning (community token = photos as links)
- Add an explicit two-path help block: A) user token via Kate Mobile (app_id 2685278) implicit OAuth, with a prefilled authorize URL, B) community token for text-only

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 15:56:20 +03:00

404 lines
14 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* VK API Client for posting to groups/walls
* Compatible with PHP 7.2+
*
* Требования:
* - VK Access Token с правами: wall, photos, groups
* - Получить токен: https://vk.com/dev → Create App → Get Token
*/
class VKAPI
{
private $accessToken;
private $apiVersion = '5.199';
private $baseUrl = 'https://api.vk.com/method/';
private $userId;
public function __construct($accessToken)
{
$this->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, []);
$result['warning'] = 'Фото добавлены как ссылки. Метод photos.getWallUploadServer не работает с токеном сообщества (это ограничение VK API: error 27 «group auth» или error 15 «access denied»). Чтобы фото прикреплялись как вложения, нужен ПОЛЬЗОВАТЕЛЬСКИЙ access token с правами wall, photos, groups, offline.';
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'];
}
}