From 65dfab75d02fa006a9ffe67f457839d09b376572 Mon Sep 17 00:00:00 2001 From: Grigory Frolov <2168057+gynsus@users.noreply.github.com> Date: Mon, 17 Aug 2026 09:32:21 +0300 Subject: [PATCH 1/8] feat: add VK (VKontakte) platform with token-based connect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit VK publishes to a community or profile wall via wall.post with a user access token (scope: wall, photos, groups, video, offline) — VK no longer grants the wall scope to new OAuth apps, so the connect flow accepts a token directly, modeled on the Bluesky credential form: validate the token, list manageable walls (own profile + administered communities), store the chosen wall as the account. Publisher uploads photos via getWallUploadServer/saveWallPhoto and videos via video.save, then attaches them to wall.post. VK reports failures as HTTP 200 with an error object; error 5 maps to TokenExpiredException, rate-limit and permission codes to their categories. --- app/Enums/PostPlatform/ContentType.php | 7 + app/Enums/SocialAccount/Platform.php | 12 + app/Exceptions/Social/VkPublishException.php | 60 ++++ app/Http/Controllers/Auth/VkController.php | 190 ++++++++++++ app/Jobs/PublishToSocialPlatform.php | 4 +- app/Models/SocialAccount.php | 3 + app/Services/Media/MediaOptimizer.php | 6 + app/Services/Social/ConnectionVerifier.php | 23 ++ app/Services/Social/Vk/VkApi.php | 36 +++ app/Services/Social/VkPublisher.php | 276 ++++++++++++++++++ config/trypost.php | 5 + database/factories/PostPlatformFactory.php | 8 + database/factories/SocialAccountFactory.php | 15 + lang/ar/accounts.php | 17 ++ lang/ar/posts.php | 5 + lang/de/accounts.php | 17 ++ lang/de/posts.php | 5 + lang/el/accounts.php | 17 ++ lang/el/posts.php | 5 + lang/en/accounts.php | 17 ++ lang/en/posts.php | 5 + lang/es/accounts.php | 17 ++ lang/es/posts.php | 5 + lang/fr/accounts.php | 17 ++ lang/fr/posts.php | 5 + lang/it/accounts.php | 17 ++ lang/it/posts.php | 5 + lang/ja/accounts.php | 17 ++ lang/ja/posts.php | 5 + lang/ko/accounts.php | 17 ++ lang/ko/posts.php | 5 + lang/nl/accounts.php | 17 ++ lang/nl/posts.php | 5 + lang/pl/accounts.php | 17 ++ lang/pl/posts.php | 5 + lang/pt-BR/accounts.php | 17 ++ lang/pt-BR/posts.php | 5 + lang/ru/accounts.php | 17 ++ lang/ru/posts.php | 5 + lang/tr/accounts.php | 17 ++ lang/tr/posts.php | 5 + lang/uk/accounts.php | 17 ++ lang/uk/posts.php | 5 + lang/zh/accounts.php | 17 ++ lang/zh/posts.php | 5 + public/images/accounts/vk.png | Bin 0 -> 1987 bytes .../posts/previews/PlatformPreview.vue | 3 + .../components/posts/previews/VkPreview.vue | 133 +++++++++ resources/js/composables/useOAuthPopup.ts | 2 + resources/js/composables/usePlatformLogo.ts | 4 + resources/js/pages/accounts/VkConnect.vue | 113 +++++++ resources/js/types/content-type.ts | 1 + resources/js/types/platform.ts | 1 + routes/app.php | 3 + .../Services/Social/VkPublisherTest.php | 127 ++++++++ tests/Feature/Social/VkControllerTest.php | 133 +++++++++ tests/Unit/Enums/PlatformTest.php | 2 + .../Social/VkPublishExceptionTest.php | 50 ++++ 58 files changed, 1568 insertions(+), 1 deletion(-) create mode 100644 app/Exceptions/Social/VkPublishException.php create mode 100644 app/Http/Controllers/Auth/VkController.php create mode 100644 app/Services/Social/Vk/VkApi.php create mode 100644 app/Services/Social/VkPublisher.php create mode 100644 public/images/accounts/vk.png create mode 100644 resources/js/components/posts/previews/VkPreview.vue create mode 100644 resources/js/pages/accounts/VkConnect.vue create mode 100644 tests/Feature/Services/Social/VkPublisherTest.php create mode 100644 tests/Feature/Social/VkControllerTest.php create mode 100644 tests/Unit/Exceptions/Social/VkPublishExceptionTest.php diff --git a/app/Enums/PostPlatform/ContentType.php b/app/Enums/PostPlatform/ContentType.php index eba15e5d1..fdff9ed9d 100644 --- a/app/Enums/PostPlatform/ContentType.php +++ b/app/Enums/PostPlatform/ContentType.php @@ -58,6 +58,8 @@ enum ContentType: string // Google Business Profile case GoogleBusinessPost = 'google_business_post'; + // VK + case VkPost = 'vk_post'; /** * AI generation format for an Instagram carousel. Not a content type — @@ -88,6 +90,7 @@ public function label(): string self::TelegramPost => 'Post', self::DiscordMessage => 'Message', self::GoogleBusinessPost => 'Post', + self::VkPost => 'Post', }; } @@ -113,6 +116,7 @@ public function platform(): SocialPlatform self::TelegramPost => SocialPlatform::Telegram, self::DiscordMessage => SocialPlatform::Discord, self::GoogleBusinessPost => SocialPlatform::GoogleBusiness, + self::VkPost => SocialPlatform::Vk, }; } @@ -183,6 +187,7 @@ public function maxMediaCount(): int self::TelegramPost => 10, self::DiscordMessage => 10, self::GoogleBusinessPost => 1, + self::VkPost => 10, }; } @@ -497,6 +502,7 @@ public function supportsVideo(): bool self::TelegramPost => true, self::DiscordMessage => true, self::GoogleBusinessPost => false, + self::VkPost => true, }; } @@ -650,6 +656,7 @@ public static function defaultFor(SocialPlatform $platform): self SocialPlatform::Telegram => self::TelegramPost, SocialPlatform::Discord => self::DiscordMessage, SocialPlatform::GoogleBusiness => self::GoogleBusinessPost, + SocialPlatform::Vk => self::VkPost, }; } } diff --git a/app/Enums/SocialAccount/Platform.php b/app/Enums/SocialAccount/Platform.php index c87258188..85bf6f042 100644 --- a/app/Enums/SocialAccount/Platform.php +++ b/app/Enums/SocialAccount/Platform.php @@ -24,6 +24,7 @@ enum Platform: string case Telegram = 'telegram'; case Discord = 'discord'; case GoogleBusiness = 'google_business'; + case Vk = 'vk'; public function network(): string { @@ -63,6 +64,7 @@ public function label(): string self::Telegram => 'Telegram', self::Discord => 'Discord', self::GoogleBusiness => 'Google Business Profile', + self::Vk => 'VK', }; } @@ -83,6 +85,7 @@ public function color(): string self::Telegram => '#26A5E4', self::Discord => '#5865F2', self::GoogleBusiness => '#4285F4', + self::Vk => '#0077FF', }; } @@ -102,6 +105,7 @@ public function allowedMediaTypes(): array self::Telegram => [MediaType::Image, MediaType::Video], self::Discord => [MediaType::Image, MediaType::Video], self::GoogleBusiness => [MediaType::Image], + self::Vk => [MediaType::Image, MediaType::Video], }; } @@ -121,6 +125,7 @@ public function maxImages(): int self::Telegram => 10, self::Discord => 10, self::GoogleBusiness => 1, + self::Vk => 10, }; } @@ -145,6 +150,7 @@ public function altTextMaxLength(): ?int self::Pinterest => 500, self::Discord => 1024, self::TikTok, self::YouTube, self::Telegram, self::GoogleBusiness => null, + self::TikTok, self::YouTube, self::Telegram, self::Vk => null, }; } @@ -179,6 +185,7 @@ public function supportsAltText(): bool * - Telegram: 4096 for a text message (media captions are capped at 1024, * handled in the publisher by sending long text as its own message) * - Google Business Profile Local Post `summary`: 1500 + * - VK: 15895 characters for a wall post */ public function maxContentLength(): int { @@ -196,6 +203,7 @@ public function maxContentLength(): int self::Telegram => 4096, self::Discord => 2000, self::GoogleBusiness => 1500, + self::Vk => 15895, }; } @@ -244,6 +252,8 @@ public function recommendedAiContentLength(): int // Google Business Profile — image does most of the work, keep the // summary tight and scannable self::GoogleBusiness => 300, + // VK — feed favors short posts; long reads live in Articles + self::Vk => 400, }; } @@ -268,6 +278,7 @@ public function requiredPublishScopes(): array self::Telegram => [], self::Discord => [], self::GoogleBusiness => ['https://www.googleapis.com/auth/business.manage'], + self::Vk => [], }; } @@ -287,6 +298,7 @@ public function supportsTextOnly(): bool self::Telegram => true, self::Discord => true, self::GoogleBusiness => true, + self::Vk => true, }; } diff --git a/app/Exceptions/Social/VkPublishException.php b/app/Exceptions/Social/VkPublishException.php new file mode 100644 index 000000000..ee86ca74c --- /dev/null +++ b/app/Exceptions/Social/VkPublishException.php @@ -0,0 +1,60 @@ +body(); + $error = $response->json('error'); + + // VK reports failures as HTTP 200 with an `error` object; transport + // failures (5xx) have no such object. + $code = (int) data_get($error, 'error_code', 0); + $message = (string) data_get($error, 'error_msg', 'An unknown VK error occurred.'); + + if (self::isConfirmedDeadToken($response)) { + throw new TokenExpiredException( + message: $message, + platformErrorCode: (string) $code, + ); + } + + return new static( + userMessage: $message, + category: match (true) { + in_array($code, [6, 9, 29], true) => ErrorCategory::RateLimit, + in_array($code, [7, 15, 200, 214, 219], true) => ErrorCategory::Permission, + in_array($code, [100, 118, 129], true) => ErrorCategory::MediaFormat, + $code === 0 && $response->serverError() => ErrorCategory::ServerError, + default => ErrorCategory::Unknown, + }, + platformErrorCode: $code > 0 ? (string) $code : (string) $response->status(), + rawResponse: $rawResponse, + ); + } + + public function platform(): string + { + return 'vk'; + } + + /** + * Whether this response confirms the account's own access_token is dead. + * VK error 5 is "User authorization failed" — the token was revoked or + * invalidated (password change, security logout). Shared with + * ConnectionVerifier so publish and verify agree on what a dead token + * looks like. + */ + public static function isConfirmedDeadToken(Response $response): bool + { + return (int) $response->json('error.error_code') === 5; + } +} diff --git a/app/Http/Controllers/Auth/VkController.php b/app/Http/Controllers/Auth/VkController.php new file mode 100644 index 000000000..2fac967c3 --- /dev/null +++ b/app/Http/Controllers/Auth/VkController.php @@ -0,0 +1,190 @@ +ensurePlatformEnabled(); + + $workspace = $request->user()->currentWorkspace; + + $this->authorize('manageAccounts', $workspace); + + return Inertia::render('accounts/VkConnect', [ + 'errors' => session('errors')?->getBag('default')?->toArray() ?? [], + ]); + } + + public function store(Request $request): InertiaResponse + { + $this->ensurePlatformEnabled(); + + $request->validate([ + 'access_token' => 'required|string|min:10', + 'owner_id' => 'nullable|integer', + ]); + + $workspace = $request->user()->currentWorkspace; + + $this->authorize('manageAccounts', $workspace); + + try { + $user = $this->callVk($request->access_token, 'users.get', [ + 'fields' => 'screen_name,photo_200', + ])[0] ?? null; + + if (! is_array($user)) { + throw ValidationException::withMessages(['access_token' => __('accounts.vk.invalid_token')]); + } + + $targets = $this->buildTargets($request->access_token, $user); + + if (! $request->filled('owner_id')) { + return Inertia::render('accounts/VkConnect', [ + 'errors' => [], + 'targets' => array_values($targets), + ]); + } + + $target = $targets[(int) $request->owner_id] ?? null; + + if ($target === null) { + throw ValidationException::withMessages(['owner_id' => __('accounts.vk.invalid_target')]); + } + + $avatarPath = $target['photo'] ? uploadFromUrl($target['photo']) : null; + + $workspace->socialAccounts()->updateOrCreate( + [ + 'platform' => $this->platform->value, + 'platform_user_id' => (string) $target['owner_id'], + ], + [ + 'username' => $target['screen_name'], + 'display_name' => $target['name'], + 'avatar_url' => $avatarPath, + 'access_token' => $request->access_token, + 'refresh_token' => null, + // vkhost/standalone tokens are issued with the `offline` + // scope and never expire; there is no refresh flow. + 'token_expires_at' => null, + 'status' => Status::Connected, + 'error_message' => null, + 'disconnected_at' => null, + 'meta' => [ + 'owner_id' => $target['owner_id'], + 'is_group' => $target['owner_id'] < 0, + 'vk_user_id' => (int) data_get($user, 'id'), + ], + ], + ); + + return $this->popupCallback(true, __('accounts.popup_callback.connected'), $this->platform->value); + } catch (ValidationException $e) { + throw $e; + } catch (\Exception $e) { + Log::error('VK connection error', [ + 'error' => $e->getMessage(), + ]); + + throw ValidationException::withMessages(['access_token' => __('accounts.vk.connection_error')]); + } + } + + /** + * Walls the token may publish to: the user's own profile plus communities + * where the user is an administrator or editor. Keyed by owner_id so the + * second form step can only pick something this token really manages. + * + * @param array $user + * @return array + */ + private function buildTargets(string $accessToken, array $user): array + { + $targets = []; + + $userId = (int) data_get($user, 'id'); + $targets[$userId] = [ + 'owner_id' => $userId, + 'name' => trim(data_get($user, 'first_name', '').' '.data_get($user, 'last_name', '')), + 'screen_name' => data_get($user, 'screen_name'), + 'photo' => data_get($user, 'photo_200'), + 'is_group' => false, + ]; + + $groups = $this->callVk($accessToken, 'groups.get', [ + 'filter' => 'admin,editor', + 'extended' => 1, + 'fields' => 'screen_name,photo_200', + 'count' => 200, + ]); + + foreach (data_get($groups, 'items', []) as $group) { + $groupId = (int) data_get($group, 'id'); + $targets[-$groupId] = [ + 'owner_id' => -$groupId, + 'name' => (string) data_get($group, 'name'), + 'screen_name' => data_get($group, 'screen_name'), + 'photo' => data_get($group, 'photo_200'), + 'is_group' => true, + ]; + } + + return $targets; + } + + /** + * Call a VK method and return its `response` payload. VK reports failures + * as HTTP 200 with an `error` object — surfaced here as a validation + * error on the token field so the form shows what VK said. + * + * @return array + */ + private function callVk(string $accessToken, string $method, array $params): array + { + $response = Http::asForm()->post( + VkApi::endpoint($method), + $params + VkApi::baseParams($accessToken), + ); + + $error = $response->json('error'); + + if ($response->failed() || $error !== null) { + Log::error('VK connect API call failed', [ + 'method' => $method, + 'status' => $response->status(), + 'error_code' => data_get($error, 'error_code'), + ]); + + throw ValidationException::withMessages([ + 'access_token' => data_get($error, 'error_msg') ?: __('accounts.vk.connection_error'), + ]); + } + + return (array) $response->json('response'); + } +} diff --git a/app/Jobs/PublishToSocialPlatform.php b/app/Jobs/PublishToSocialPlatform.php index 096463a89..6801f5483 100644 --- a/app/Jobs/PublishToSocialPlatform.php +++ b/app/Jobs/PublishToSocialPlatform.php @@ -29,6 +29,7 @@ use App\Services\Social\Telegram\TelegramPublisher; use App\Services\Social\ThreadsPublisher; use App\Services\Social\TikTokPublisher; +use App\Services\Social\VkPublisher; use App\Services\Social\XPublisher; use App\Services\Social\YouTubePublisher; use App\Support\Social\GoogleBusinessDerivativeCleaner; @@ -416,7 +417,7 @@ private function safeFailureMessage(Throwable $e): string : 'An unexpected error occurred while publishing. Please try again.'; } - private function getPublisher(): LinkedInPublisher|LinkedInPagePublisher|XPublisher|TikTokPublisher|YouTubePublisher|FacebookPublisher|InstagramPublisher|ThreadsPublisher|PinterestPublisher|BlueskyPublisher|MastodonPublisher|TelegramPublisher|DiscordPublisher|GoogleBusinessPublisher + private function getPublisher(): LinkedInPublisher|LinkedInPagePublisher|XPublisher|TikTokPublisher|YouTubePublisher|FacebookPublisher|InstagramPublisher|ThreadsPublisher|PinterestPublisher|BlueskyPublisher|MastodonPublisher|TelegramPublisher|DiscordPublisher|GoogleBusinessPublisher|VkPublisher { return match ($this->postPlatform->platform) { SocialPlatform::LinkedIn => app(LinkedInPublisher::class), @@ -433,6 +434,7 @@ private function getPublisher(): LinkedInPublisher|LinkedInPagePublisher|XPublis SocialPlatform::Telegram => app(TelegramPublisher::class), SocialPlatform::Discord => app(DiscordPublisher::class), SocialPlatform::GoogleBusiness => app(GoogleBusinessPublisher::class), + SocialPlatform::Vk => app(VkPublisher::class), }; } diff --git a/app/Models/SocialAccount.php b/app/Models/SocialAccount.php index 804836ff2..48c71acae 100644 --- a/app/Models/SocialAccount.php +++ b/app/Models/SocialAccount.php @@ -280,6 +280,9 @@ protected function profileUrl(): Attribute : (filled(data_get($this->meta, 'location_id')) ? GoogleBusinessResourceName::dashboardUrl((string) data_get($this->meta, 'location_id')) : null), + SocialPlatform::Vk => $username + ? "https://vk.com/{$username}" + : ($platformUserId ? 'https://vk.com/'.(str_starts_with($platformUserId, '-') ? 'club'.ltrim($platformUserId, '-') : "id{$platformUserId}") : null), default => null, }; }, diff --git a/app/Services/Media/MediaOptimizer.php b/app/Services/Media/MediaOptimizer.php index 35ed77b94..1700bcd87 100644 --- a/app/Services/Media/MediaOptimizer.php +++ b/app/Services/Media/MediaOptimizer.php @@ -360,6 +360,12 @@ private function getImageConfig(Platform $platform): array 'format' => 'image/jpeg', 'quality' => 100, ], + Platform::Vk => [ + 'max_width' => 2560, + 'max_size' => 50 * 1024 * 1024, + 'format' => 'image/jpeg', + 'quality' => 100, + ], }; } } diff --git a/app/Services/Social/ConnectionVerifier.php b/app/Services/Social/ConnectionVerifier.php index e0ef33965..595b43286 100644 --- a/app/Services/Social/ConnectionVerifier.php +++ b/app/Services/Social/ConnectionVerifier.php @@ -11,6 +11,7 @@ use App\Exceptions\Social\GoogleBusinessPublishException; use App\Exceptions\Social\LinkedInPublishException; use App\Exceptions\Social\MastodonPublishException; +use App\Exceptions\Social\VkPublishException; use App\Exceptions\Social\PinterestPublishException; use App\Exceptions\Social\TelegramPublishException; use App\Exceptions\Social\TikTokPublishException; @@ -203,6 +204,7 @@ private function callVerifyEndpoint(SocialAccount $account): bool Platform::Telegram => $this->verifyTelegram($account), Platform::Discord => $this->verifyDiscord($account), Platform::GoogleBusiness => $this->verifyGoogleBusiness($account), + Platform::Vk => $this->verifyVk($account), }; } @@ -801,4 +803,25 @@ private function verifyGoogleBusiness(SocialAccount $account): bool $response->status(), ); } + + private function verifyVk(SocialAccount $account): bool + { + $response = Http::asForm()->post( + \App\Services\Social\Vk\VkApi::endpoint('users.get'), + \App\Services\Social\Vk\VkApi::baseParams($account->access_token), + ); + + if (VkPublishException::isConfirmedDeadToken($response)) { + throw new TokenExpiredException('VK access token is invalid or revoked'); + } + + if ($response->successful() && $response->json('error') === null) { + return true; + } + + throw new PlatformUnavailableException( + "{$account->platform->label()} verify failed ({$response->status()}).", + $response->status(), + ); + } } diff --git a/app/Services/Social/Vk/VkApi.php b/app/Services/Social/Vk/VkApi.php new file mode 100644 index 000000000..6d5b9602e --- /dev/null +++ b/app/Services/Social/Vk/VkApi.php @@ -0,0 +1,36 @@ + $accessToken, + 'v' => (string) config('trypost.platforms.vk.api_version'), + ]; + } +} diff --git a/app/Services/Social/VkPublisher.php b/app/Services/Social/VkPublisher.php new file mode 100644 index 000000000..7cfe62356 --- /dev/null +++ b/app/Services/Social/VkPublisher.php @@ -0,0 +1,276 @@ +validateContentLength($postPlatform); + + $content = $postPlatform->post->content + ? app(ContentSanitizer::class)->sanitize($postPlatform->post->content, $postPlatform->platform) + : null; + + $account = $postPlatform->socialAccount; + $ownerId = $this->ownerId($account); + + $attachments = []; + + foreach ($postPlatform->post->mediaItems->take($postPlatform->platform->maxImages()) as $media) { + $attachment = match (true) { + $media->isImage() => $this->uploadPhoto($account, $ownerId, $media->url), + $media->isVideo() => $this->uploadVideo($account, $ownerId, $media->url, $content), + default => null, + }; + + if ($attachment !== null) { + $attachments[] = $attachment; + } + } + + $params = [ + 'owner_id' => $ownerId, + 'message' => $content ?? '', + ]; + + if ($ownerId < 0) { + // Publish as the community itself, not as the connecting user. + $params['from_group'] = 1; + } + + if ($attachments !== []) { + $params['attachments'] = implode(',', $attachments); + } + + $response = $this->socialHttp()->asForm()->post( + VkApi::endpoint('wall.post'), + $params + VkApi::baseParams($account->access_token), + ); + + $postId = $response->json('response.post_id'); + + if ($response->failed() || $postId === null) { + Log::error('VK post creation failed', [ + 'status' => $response->status(), + 'body' => $this->redactResponseBody($response->body()), + ]); + $this->handleApiError($response); + } + + return [ + 'id' => (string) $postId, + 'url' => "https://vk.com/wall{$ownerId}_{$postId}", + ]; + } + + /** + * The wall to publish to: negative id for a community, positive for the + * user's own profile wall. Stored at connect time; platform_user_id keeps + * the same value as a fallback for rows created before meta existed. + */ + private function ownerId(SocialAccount $account): int + { + return (int) (data_get($account->meta, 'owner_id') ?? $account->platform_user_id); + } + + /** + * VK photo upload is a three-step flow: getWallUploadServer → POST the + * file to the returned upload_url → saveWallPhoto. Returns an attachment + * reference like `photo123_456`, or null to skip the item (a failed + * single photo should not sink the whole post; wall.post itself decides + * whether an empty post is acceptable). + */ + private function uploadPhoto(SocialAccount $account, int $ownerId, string $url): ?string + { + $groupParams = $ownerId < 0 ? ['group_id' => abs($ownerId)] : []; + $tempFile = tempnam(sys_get_temp_dir(), 'vk_media_'); + + try { + $download = Http::withOptions(['sink' => $tempFile])->timeout(600)->get($url); + + if ($download->failed() || filesize($tempFile) === 0) { + Log::error('VK failed to download media', ['url' => $url]); + + return null; + } + + $detectedMime = mime_content_type($tempFile) ?: ''; + if (MediaType::classify($detectedMime) === MediaType::Image && ! MediaType::isGif($detectedMime)) { + $optimizer = app(MediaOptimizer::class); + $optimizedPath = $optimizer->optimizeImage($tempFile, Platform::Vk); + @unlink($tempFile); + $tempFile = $optimizedPath; + } + + $server = $this->call($account, 'photos.getWallUploadServer', $groupParams); + $uploadUrl = data_get($server, 'response.upload_server') ?? data_get($server, 'response.upload_url'); + + if (! is_string($uploadUrl) || $uploadUrl === '') { + Log::error('VK getWallUploadServer returned no upload_url', ['body' => $this->redactResponseBody(json_encode($server) ?: '')]); + + return null; + } + + $stream = fopen($tempFile, 'r'); + $upload = $this->socialHttp() + ->attach('photo', $stream, 'photo.jpg') + ->post($uploadUrl); + + if (is_resource($stream)) { + fclose($stream); + } + + if ($upload->failed() || data_get($upload->json(), 'photo') === null) { + Log::error('VK photo upload failed', [ + 'status' => $upload->status(), + 'body' => $this->redactResponseBody($upload->body()), + ]); + + return null; + } + + $saved = $this->call($account, 'photos.saveWallPhoto', $groupParams + [ + 'photo' => (string) data_get($upload->json(), 'photo'), + 'server' => (string) data_get($upload->json(), 'server'), + 'hash' => (string) data_get($upload->json(), 'hash'), + ]); + + $photo = data_get($saved, 'response.0'); + + if (! is_array($photo)) { + Log::error('VK saveWallPhoto failed', ['body' => $this->redactResponseBody(json_encode($saved) ?: '')]); + + return null; + } + + return 'photo'.data_get($photo, 'owner_id').'_'.data_get($photo, 'id'); + } catch (\Exception $e) { + Log::error('VK photo upload error', ['error' => $e->getMessage(), 'url' => $url]); + + return null; + } finally { + @unlink($tempFile); + } + } + + /** + * VK video upload: video.save returns an upload_url the raw file is + * POSTed to; the attachment id comes from video.save itself. `wallpost=0` + * keeps VK from auto-publishing — the video is attached to our wall.post. + * Requires the token to carry the `video` scope; a missing scope surfaces + * as an API error and the item is skipped. + */ + private function uploadVideo(SocialAccount $account, int $ownerId, string $url, ?string $content): ?string + { + $tempFile = tempnam(sys_get_temp_dir(), 'vk_video_'); + + try { + $download = Http::withOptions(['sink' => $tempFile])->timeout(600)->get($url); + + if ($download->failed() || filesize($tempFile) === 0) { + Log::error('VK failed to download video', ['url' => $url]); + + return null; + } + + $name = $content !== null && $content !== '' + ? mb_substr($content, 0, 100) + : 'Video'; + + $save = $this->call($account, 'video.save', array_filter([ + 'group_id' => $ownerId < 0 ? abs($ownerId) : null, + 'name' => $name, + 'wallpost' => 0, + ], fn ($value) => $value !== null)); + + $uploadUrl = data_get($save, 'response.upload_url'); + + if (! is_string($uploadUrl) || $uploadUrl === '') { + Log::error('VK video.save returned no upload_url', ['body' => $this->redactResponseBody(json_encode($save) ?: '')]); + + return null; + } + + $stream = fopen($tempFile, 'r'); + $upload = $this->socialHttp() + ->timeout(600) + ->attach('video_file', $stream, 'video.mp4') + ->post($uploadUrl); + + if (is_resource($stream)) { + fclose($stream); + } + + if ($upload->failed()) { + Log::error('VK video upload failed', [ + 'status' => $upload->status(), + 'body' => $this->redactResponseBody($upload->body()), + ]); + + return null; + } + + $videoOwner = data_get($save, 'response.owner_id'); + $videoId = data_get($upload->json(), 'video_id') ?? data_get($save, 'response.video_id'); + + if ($videoOwner === null || $videoId === null) { + return null; + } + + return "video{$videoOwner}_{$videoId}"; + } catch (\Exception $e) { + Log::error('VK video upload error', ['error' => $e->getMessage(), 'url' => $url]); + + return null; + } finally { + @unlink($tempFile); + } + } + + /** + * Call a VK API method and return the decoded body. VK signals errors as + * HTTP 200 + an `error` object, so both transport failures and API errors + * funnel through the same exception path. + * + * @return array + */ + private function call(SocialAccount $account, string $method, array $params): array + { + $response = $this->socialHttp()->asForm()->post( + VkApi::endpoint($method), + $params + VkApi::baseParams($account->access_token), + ); + + if ($response->failed() || $response->json('error') !== null) { + Log::error("VK {$method} failed", [ + 'status' => $response->status(), + 'body' => $this->redactResponseBody($response->body()), + ]); + $this->handleApiError($response); + } + + return $response->json() ?? []; + } + + private function handleApiError(Response $response): never + { + throw VkPublishException::fromApiResponse($response); + } +} diff --git a/config/trypost.php b/config/trypost.php index 6b065c0f4..2b3ee5caf 100644 --- a/config/trypost.php +++ b/config/trypost.php @@ -261,6 +261,11 @@ // Secret-token header Telegram echoes on every webhook call. 'webhook_secret' => env('TELEGRAM_WEBHOOK_SECRET'), ], + 'vk' => [ + 'enabled' => env('VK_ENABLED', true), + 'api' => env('VK_API', 'https://api.vk.com/method'), + 'api_version' => env('VK_API_VERSION', '5.199'), + ], 'discord' => [ 'enabled' => env('DISCORD_ENABLED', true), // Single shared bot application. OAuth (bot scope) authorizes adding the diff --git a/database/factories/PostPlatformFactory.php b/database/factories/PostPlatformFactory.php index 72da643ee..af65c1432 100644 --- a/database/factories/PostPlatformFactory.php +++ b/database/factories/PostPlatformFactory.php @@ -99,6 +99,14 @@ public function bluesky(): static ]); } + public function vk(): static + { + return $this->state(fn (array $attributes) => [ + 'platform' => Platform::Vk, + 'content_type' => ContentType::VkPost, + ]); + } + public function mastodon(): static { return $this->state(fn (array $attributes) => [ diff --git a/database/factories/SocialAccountFactory.php b/database/factories/SocialAccountFactory.php index 3087f9fb9..5265e6005 100644 --- a/database/factories/SocialAccountFactory.php +++ b/database/factories/SocialAccountFactory.php @@ -137,6 +137,21 @@ public function bluesky(): static ]); } + public function vk(): static + { + return $this->state(fn (array $attributes) => [ + 'platform' => Platform::Vk, + 'platform_user_id' => '-123456', + 'scopes' => Platform::Vk->requiredPublishScopes(), + 'token_expires_at' => null, + 'meta' => [ + 'owner_id' => -123456, + 'is_group' => true, + 'vk_user_id' => 111, + ], + ]); + } + public function mastodon(): static { return $this->state(fn (array $attributes) => [ diff --git a/lang/ar/accounts.php b/lang/ar/accounts.php index 204b0dae4..6a513a25a 100644 --- a/lang/ar/accounts.php +++ b/lang/ar/accounts.php @@ -42,6 +42,7 @@ 'telegram' => 'اربط قناة أو مجموعة على Telegram', 'discord' => 'اربط خادم Discord', 'google_business' => 'اربط موقع Google Business Profile', + 'vk' => 'اربط مجتمع VK أو ملفًا شخصيًا', ], 'disconnect_modal' => [ @@ -63,6 +64,22 @@ 'submitting' => 'جارٍ الاتصال...', ], + 'vk' => [ + 'title' => 'ربط VK', + 'description' => 'انشر في مجتمع أو على حائطك الشخصي', + 'access_token' => 'رمز الوصول', + 'access_token_placeholder' => 'vk1.a.…', + 'access_token_hint' => 'الصق رمز وصول مستخدم من VK بصلاحيات wall, photos, groups, video, offline. لم يعد VK يمنح صلاحية wall لتطبيقات OAuth الجديدة، فاستخدم رمزًا من تطبيق standalone خاص بك (أو خدمة مثل vkhost).', + 'pick_target' => 'اختر وجهة النشر', + 'target_group' => 'مجتمع', + 'target_profile' => 'ملف شخصي', + 'invalid_token' => 'رفض VK هذا الرمز.', + 'invalid_target' => 'لا يمكن إدارة هذا الحائط بالرمز المقدّم.', + 'connection_error' => 'خطأ في الاتصال بـ VK. حاول مرة أخرى.', + 'submit' => 'ربط VK', + 'submitting' => 'جارٍ الاتصال...', + ], + 'mastodon' => [ 'title' => 'ربط Mastodon', 'description' => 'أدخل خادم Mastodon الخاص بك', diff --git a/lang/ar/posts.php b/lang/ar/posts.php index 5bd4cb358..07259f96e 100644 --- a/lang/ar/posts.php +++ b/lang/ar/posts.php @@ -602,6 +602,10 @@ 'label' => 'منشور', 'description' => 'يظهر على ملفك التجاري في البحث والخرائط', ], + 'vk_post' => [ + 'label' => 'منشور', + 'description' => 'منشور نصي مع وسائط اختيارية', + ], ], 'platforms' => [ @@ -727,6 +731,7 @@ 'mastodon_post' => 'منشور Mastodon', 'telegram_post' => 'منشور Telegram', 'discord_message' => 'رسالة Discord', + 'vk_post' => 'منشور VK', 'facebook_post' => 'منشور Facebook', 'pinterest_pin' => 'دبوس Pinterest', 'instagram_story' => 'قصة Instagram', diff --git a/lang/de/accounts.php b/lang/de/accounts.php index d6e306f26..47d0aeade 100644 --- a/lang/de/accounts.php +++ b/lang/de/accounts.php @@ -44,6 +44,7 @@ 'telegram' => 'Verbinde einen Telegram-Kanal oder eine Telegram-Gruppe', 'discord' => 'Verbinde einen Discord-Server', 'google_business' => 'Verbinde einen Google Unternehmensprofil-Standort', + 'vk' => 'Verbinde eine VK-Community oder ein Profil', ], 'disconnect_modal' => [ @@ -65,6 +66,22 @@ 'submitting' => 'Verbindung wird hergestellt...', ], + 'vk' => [ + 'title' => 'VK verbinden', + 'description' => 'In einer Community oder auf deiner Pinnwand veröffentlichen', + 'access_token' => 'Zugriffstoken', + 'access_token_placeholder' => 'vk1.a.…', + 'access_token_hint' => 'Füge ein VK-Benutzer-Zugriffstoken mit den Berechtigungen wall, photos, groups, video, offline ein. Neuen OAuth-Apps gewährt VK die wall-Berechtigung nicht mehr; verwende daher ein Token einer eigenen Standalone-App (oder einen Dienst wie vkhost).', + 'pick_target' => 'Wo veröffentlichen?', + 'target_group' => 'Community', + 'target_profile' => 'Persönliches Profil', + 'invalid_token' => 'VK hat dieses Token abgelehnt.', + 'invalid_target' => 'Diese Pinnwand ist mit dem angegebenen Token nicht verwaltbar.', + 'connection_error' => 'Fehler beim Verbinden mit VK. Bitte erneut versuchen.', + 'submit' => 'VK verbinden', + 'submitting' => 'Verbinden...', + ], + 'mastodon' => [ 'title' => 'Mastodon verbinden', 'description' => 'Gib deine Mastodon-Instanz ein', diff --git a/lang/de/posts.php b/lang/de/posts.php index f8ab34c30..8f726f65d 100644 --- a/lang/de/posts.php +++ b/lang/de/posts.php @@ -604,6 +604,10 @@ 'label' => 'Beitrag', 'description' => 'Wird in deinem Geschäftsprofil in Suche und Karten angezeigt', ], + 'vk_post' => [ + 'label' => 'Beitrag', + 'description' => 'Textbeitrag mit optionalen Medien', + ], ], 'platforms' => [ @@ -729,6 +733,7 @@ 'mastodon_post' => 'Mastodon-Beitrag', 'telegram_post' => 'Telegram-Beitrag', 'discord_message' => 'Discord-Nachricht', + 'vk_post' => 'VK-Beitrag', 'facebook_post' => 'Facebook-Beitrag', 'pinterest_pin' => 'Pinterest-Pin', 'instagram_story' => 'Instagram-Story', diff --git a/lang/el/accounts.php b/lang/el/accounts.php index d66f1fb71..2ccbddc5c 100644 --- a/lang/el/accounts.php +++ b/lang/el/accounts.php @@ -42,6 +42,7 @@ 'telegram' => 'Συνδέστε ένα κανάλι ή ομάδα Telegram', 'discord' => 'Συνδέστε έναν διακομιστή Discord', 'google_business' => 'Συνδέστε μια τοποθεσία Google Business Profile', + 'vk' => 'Συνδέστε μια κοινότητα ή ένα προφίλ VK', ], 'disconnect_modal' => [ @@ -63,6 +64,22 @@ 'submitting' => 'Σύνδεση...', ], + 'vk' => [ + 'title' => 'Σύνδεση VK', + 'description' => 'Δημοσιεύστε σε κοινότητα ή στον τοίχο σας', + 'access_token' => 'Διακριτικό πρόσβασης', + 'access_token_placeholder' => 'vk1.a.…', + 'access_token_hint' => 'Επικολλήστε ένα διακριτικό πρόσβασης χρήστη VK με τα δικαιώματα wall, photos, groups, video, offline. Το VK δεν παραχωρεί πλέον το δικαίωμα wall σε νέες εφαρμογές OAuth· χρησιμοποιήστε διακριτικό από δική σας standalone εφαρμογή (ή υπηρεσία όπως το vkhost).', + 'pick_target' => 'Πού να δημοσιευτεί', + 'target_group' => 'Κοινότητα', + 'target_profile' => 'Προσωπικό προφίλ', + 'invalid_token' => 'Το VK απέρριψε αυτό το διακριτικό.', + 'invalid_target' => 'Αυτός ο τοίχος δεν μπορεί να διαχειριστεί με το παρεχόμενο διακριτικό.', + 'connection_error' => 'Σφάλμα σύνδεσης με το VK. Δοκιμάστε ξανά.', + 'submit' => 'Σύνδεση VK', + 'submitting' => 'Σύνδεση...', + ], + 'mastodon' => [ 'title' => 'Σύνδεση Mastodon', 'description' => 'Εισάγετε το instance του Mastodon σας', diff --git a/lang/el/posts.php b/lang/el/posts.php index 4da982a4f..c12716fde 100644 --- a/lang/el/posts.php +++ b/lang/el/posts.php @@ -602,6 +602,10 @@ 'label' => 'Δημοσίευση', 'description' => 'Εμφανίζεται στο Επιχειρηματικό σας Προφίλ στην Αναζήτηση και τους Χάρτες', ], + 'vk_post' => [ + 'label' => 'Ανάρτηση', + 'description' => 'Ανάρτηση κειμένου με προαιρετικά πολυμέσα', + ], ], 'platforms' => [ @@ -727,6 +731,7 @@ 'mastodon_post' => 'Δημοσίευση Mastodon', 'telegram_post' => 'Δημοσίευση Telegram', 'discord_message' => 'Μήνυμα Discord', + 'vk_post' => 'Ανάρτηση VK', 'facebook_post' => 'Δημοσίευση Facebook', 'pinterest_pin' => 'Pin Pinterest', 'instagram_story' => 'Story Instagram', diff --git a/lang/en/accounts.php b/lang/en/accounts.php index 9e5882aff..c1f094fc1 100644 --- a/lang/en/accounts.php +++ b/lang/en/accounts.php @@ -42,6 +42,7 @@ 'telegram' => 'Connect a Telegram channel or group', 'discord' => 'Connect a Discord server', 'google_business' => 'Connect a Google Business Profile location', + 'vk' => 'Connect a VK community or profile', ], 'disconnect_modal' => [ @@ -63,6 +64,22 @@ 'submitting' => 'Connecting...', ], + 'vk' => [ + 'title' => 'Connect VK', + 'description' => 'Publish to a community or your profile wall', + 'access_token' => 'Access token', + 'access_token_placeholder' => 'vk1.a.…', + 'access_token_hint' => 'Paste a VK user access token with the wall, photos, groups, video, offline scopes. VK no longer grants the wall scope to new OAuth apps, so use a token from a standalone app you own (or a service like vkhost).', + 'pick_target' => 'Choose where to publish', + 'target_group' => 'Community', + 'target_profile' => 'Personal profile', + 'invalid_token' => 'VK rejected this token.', + 'invalid_target' => 'This wall cannot be managed with the provided token.', + 'connection_error' => 'Error connecting to VK. Please try again.', + 'submit' => 'Connect VK', + 'submitting' => 'Connecting...', + ], + 'mastodon' => [ 'title' => 'Connect Mastodon', 'description' => 'Enter your Mastodon instance', diff --git a/lang/en/posts.php b/lang/en/posts.php index 12a91b91d..29cd3782d 100644 --- a/lang/en/posts.php +++ b/lang/en/posts.php @@ -602,6 +602,10 @@ 'label' => 'Post', 'description' => 'Appears on your Business Profile in Search and Maps', ], + 'vk_post' => [ + 'label' => 'Post', + 'description' => 'Text post with optional media', + ], ], 'platforms' => [ @@ -727,6 +731,7 @@ 'mastodon_post' => 'Mastodon Post', 'telegram_post' => 'Telegram Post', 'discord_message' => 'Discord Message', + 'vk_post' => 'VK Post', 'facebook_post' => 'Facebook Post', 'pinterest_pin' => 'Pinterest Pin', 'instagram_story' => 'Instagram Story', diff --git a/lang/es/accounts.php b/lang/es/accounts.php index 684628a3b..7571de47b 100644 --- a/lang/es/accounts.php +++ b/lang/es/accounts.php @@ -42,6 +42,7 @@ 'telegram' => 'Conecta un canal o grupo de Telegram', 'discord' => 'Conecta un servidor de Discord', 'google_business' => 'Conecta una ubicación de Google Business Profile', + 'vk' => 'Conecta una comunidad o un perfil de VK', ], 'disconnect_modal' => [ @@ -63,6 +64,22 @@ 'submitting' => 'Conectando...', ], + 'vk' => [ + 'title' => 'Conectar VK', + 'description' => 'Publica en una comunidad o en tu propio muro', + 'access_token' => 'Token de acceso', + 'access_token_placeholder' => 'vk1.a.…', + 'access_token_hint' => 'Pega un token de acceso de usuario de VK con los permisos wall, photos, groups, video, offline. VK ya no concede el permiso wall a nuevas apps OAuth; usa un token de una app standalone propia (o un servicio como vkhost).', + 'pick_target' => 'Dónde publicar', + 'target_group' => 'Comunidad', + 'target_profile' => 'Perfil personal', + 'invalid_token' => 'VK rechazó este token.', + 'invalid_target' => 'Este muro no se puede gestionar con el token indicado.', + 'connection_error' => 'Error al conectar con VK. Inténtalo de nuevo.', + 'submit' => 'Conectar VK', + 'submitting' => 'Conectando...', + ], + 'mastodon' => [ 'title' => 'Conectar Mastodon', 'description' => 'Introduce tu instancia de Mastodon', diff --git a/lang/es/posts.php b/lang/es/posts.php index ce9c2e387..2139bf277 100644 --- a/lang/es/posts.php +++ b/lang/es/posts.php @@ -602,6 +602,10 @@ 'label' => 'Publicación', 'description' => 'Aparece en tu Perfil Empresarial en Búsqueda y Mapas', ], + 'vk_post' => [ + 'label' => 'Publicación', + 'description' => 'Publicación de texto con medios opcionales', + ], ], 'platforms' => [ @@ -728,6 +732,7 @@ 'mastodon_post' => 'Post en Mastodon', 'telegram_post' => 'Post en Telegram', 'discord_message' => 'Mensaje de Discord', + 'vk_post' => 'Publicación de VK', 'facebook_post' => 'Post en Facebook', 'pinterest_pin' => 'Pin de Pinterest', 'instagram_story' => 'Story de Instagram', diff --git a/lang/fr/accounts.php b/lang/fr/accounts.php index 7a1a4f2d0..cb118e9a7 100644 --- a/lang/fr/accounts.php +++ b/lang/fr/accounts.php @@ -42,6 +42,7 @@ 'telegram' => 'Connectez un canal ou un groupe Telegram', 'discord' => 'Connectez un serveur Discord', 'google_business' => 'Connectez un établissement Google Business Profile', + 'vk' => 'Connectez une communauté ou un profil VK', ], 'disconnect_modal' => [ @@ -63,6 +64,22 @@ 'submitting' => 'Connexion...', ], + 'vk' => [ + 'title' => 'Connecter VK', + 'description' => 'Publier dans une communauté ou sur votre mur', + 'access_token' => 'Jeton d\'accès', + 'access_token_placeholder' => 'vk1.a.…', + 'access_token_hint' => 'Collez un jeton d\'accès utilisateur VK avec les autorisations wall, photos, groups, video, offline. VK n\'accorde plus l\'autorisation wall aux nouvelles applications OAuth ; utilisez un jeton d\'une application standalone à vous (ou un service comme vkhost).', + 'pick_target' => 'Où publier', + 'target_group' => 'Communauté', + 'target_profile' => 'Profil personnel', + 'invalid_token' => 'VK a rejeté ce jeton.', + 'invalid_target' => 'Ce mur ne peut pas être géré avec le jeton fourni.', + 'connection_error' => 'Erreur de connexion à VK. Veuillez réessayer.', + 'submit' => 'Connecter VK', + 'submitting' => 'Connexion...', + ], + 'mastodon' => [ 'title' => 'Connecter Mastodon', 'description' => 'Saisissez votre instance Mastodon', diff --git a/lang/fr/posts.php b/lang/fr/posts.php index 59b72e357..5d9e0fc35 100644 --- a/lang/fr/posts.php +++ b/lang/fr/posts.php @@ -602,6 +602,10 @@ 'label' => 'Publication', 'description' => 'Apparaît dans votre Profil Entreprise dans la Recherche et Cartes', ], + 'vk_post' => [ + 'label' => 'Publication', + 'description' => 'Publication texte avec médias facultatifs', + ], ], 'platforms' => [ @@ -727,6 +731,7 @@ 'mastodon_post' => 'Publication Mastodon', 'telegram_post' => 'Publication Telegram', 'discord_message' => 'Message Discord', + 'vk_post' => 'Publication VK', 'facebook_post' => 'Publication Facebook', 'pinterest_pin' => 'Épingle Pinterest', 'instagram_story' => 'Story Instagram', diff --git a/lang/it/accounts.php b/lang/it/accounts.php index 1ea5fd2a9..c2fcf4f78 100644 --- a/lang/it/accounts.php +++ b/lang/it/accounts.php @@ -42,6 +42,7 @@ 'telegram' => 'Collega un canale o gruppo Telegram', 'discord' => 'Collega un server Discord', 'google_business' => 'Collega una sede di Google Business Profile', + 'vk' => 'Collega una community o un profilo VK', ], 'disconnect_modal' => [ @@ -63,6 +64,22 @@ 'submitting' => 'Collegamento in corso...', ], + 'vk' => [ + 'title' => 'Collega VK', + 'description' => 'Pubblica in una community o sulla tua bacheca', + 'access_token' => 'Token di accesso', + 'access_token_placeholder' => 'vk1.a.…', + 'access_token_hint' => 'Incolla un token di accesso utente VK con i permessi wall, photos, groups, video, offline. VK non concede più il permesso wall alle nuove app OAuth; usa un token di una tua app standalone (o un servizio come vkhost).', + 'pick_target' => 'Dove pubblicare', + 'target_group' => 'Community', + 'target_profile' => 'Profilo personale', + 'invalid_token' => 'VK ha rifiutato questo token.', + 'invalid_target' => 'Questa bacheca non è gestibile con il token fornito.', + 'connection_error' => 'Errore di connessione a VK. Riprova.', + 'submit' => 'Collega VK', + 'submitting' => 'Connessione...', + ], + 'mastodon' => [ 'title' => 'Collega Mastodon', 'description' => 'Inserisci la tua istanza Mastodon', diff --git a/lang/it/posts.php b/lang/it/posts.php index c03e75e11..e553e22d4 100644 --- a/lang/it/posts.php +++ b/lang/it/posts.php @@ -602,6 +602,10 @@ 'label' => 'Pubblicazione', 'description' => 'Appare nel tuo Profilo Aziendale in Ricerca e Mappe', ], + 'vk_post' => [ + 'label' => 'Post', + 'description' => 'Post di testo con media facoltativi', + ], ], 'platforms' => [ @@ -727,6 +731,7 @@ 'mastodon_post' => 'Post Mastodon', 'telegram_post' => 'Post Telegram', 'discord_message' => 'Messaggio Discord', + 'vk_post' => 'Post VK', 'facebook_post' => 'Post Facebook', 'pinterest_pin' => 'Pin Pinterest', 'instagram_story' => 'Storia Instagram', diff --git a/lang/ja/accounts.php b/lang/ja/accounts.php index 0f0d63295..3879ee5cf 100644 --- a/lang/ja/accounts.php +++ b/lang/ja/accounts.php @@ -42,6 +42,7 @@ 'telegram' => 'Telegram チャンネルまたはグループを接続', 'discord' => 'Discord サーバーを接続', 'google_business' => 'Google ビジネス プロフィールの店舗を接続', + 'vk' => 'VKのコミュニティまたはプロフィールを連携', ], 'disconnect_modal' => [ @@ -63,6 +64,22 @@ 'submitting' => '接続中...', ], + 'vk' => [ + 'title' => 'VKを連携', + 'description' => 'コミュニティまたは自分のウォールに投稿します', + 'access_token' => 'アクセストークン', + 'access_token_placeholder' => 'vk1.a.…', + 'access_token_hint' => 'wall, photos, groups, video, offline スコープ付きのVKユーザーアクセストークンを貼り付けてください。VKは新しいOAuthアプリにwallスコープを付与しなくなったため、自分のスタンドアロンアプリのトークン(またはvkhostのようなサービス)を使用してください。', + 'pick_target' => '投稿先を選択', + 'target_group' => 'コミュニティ', + 'target_profile' => '個人プロフィール', + 'invalid_token' => 'VKがこのトークンを拒否しました。', + 'invalid_target' => 'このウォールは指定されたトークンでは管理できません。', + 'connection_error' => 'VKへの接続エラーです。もう一度お試しください。', + 'submit' => 'VKを連携', + 'submitting' => '接続中...', + ], + 'mastodon' => [ 'title' => 'Mastodon を接続', 'description' => 'Mastodon のインスタンスを入力してください', diff --git a/lang/ja/posts.php b/lang/ja/posts.php index e066e8686..ba23821d8 100644 --- a/lang/ja/posts.php +++ b/lang/ja/posts.php @@ -602,6 +602,10 @@ 'label' => '投稿', 'description' => 'ビジネス プロフィールに検索とマップで表示されます', ], + 'vk_post' => [ + 'label' => '投稿', + 'description' => 'メディア添付可能なテキスト投稿', + ], ], 'platforms' => [ @@ -727,6 +731,7 @@ 'mastodon_post' => 'Mastodon 投稿', 'telegram_post' => 'Telegram 投稿', 'discord_message' => 'Discord メッセージ', + 'vk_post' => 'VK投稿', 'facebook_post' => 'Facebook 投稿', 'pinterest_pin' => 'Pinterest ピン', 'instagram_story' => 'Instagram ストーリー', diff --git a/lang/ko/accounts.php b/lang/ko/accounts.php index 86b63384f..66894e982 100644 --- a/lang/ko/accounts.php +++ b/lang/ko/accounts.php @@ -42,6 +42,7 @@ 'telegram' => 'Telegram 채널 또는 그룹을 연결하세요', 'discord' => 'Discord 서버를 연결하세요', 'google_business' => 'Google 비즈니스 프로필 위치를 연결하세요', + 'vk' => 'VK 커뮤니티 또는 프로필 연결', ], 'disconnect_modal' => [ @@ -63,6 +64,22 @@ 'submitting' => '연결 중...', ], + 'vk' => [ + 'title' => 'VK 연결', + 'description' => '커뮤니티 또는 내 담벼락에 게시합니다', + 'access_token' => '액세스 토큰', + 'access_token_placeholder' => 'vk1.a.…', + 'access_token_hint' => 'wall, photos, groups, video, offline 범위가 있는 VK 사용자 액세스 토큰을 붙여넣으세요. VK는 새 OAuth 앱에 더 이상 wall 범위를 부여하지 않으므로, 본인 소유 standalone 앱의 토큰(또는 vkhost 같은 서비스)을 사용하세요.', + 'pick_target' => '게시 위치 선택', + 'target_group' => '커뮤니티', + 'target_profile' => '개인 프로필', + 'invalid_token' => 'VK가 이 토큰을 거부했습니다.', + 'invalid_target' => '제공된 토큰으로는 이 담벼락을 관리할 수 없습니다.', + 'connection_error' => 'VK 연결 중 오류가 발생했습니다. 다시 시도해 주세요.', + 'submit' => 'VK 연결', + 'submitting' => '연결 중...', + ], + 'mastodon' => [ 'title' => 'Mastodon 연결', 'description' => 'Mastodon 인스턴스를 입력하세요', diff --git a/lang/ko/posts.php b/lang/ko/posts.php index 13bfc4421..90e619887 100644 --- a/lang/ko/posts.php +++ b/lang/ko/posts.php @@ -602,6 +602,10 @@ 'label' => '게시물', 'description' => '비즈니스 프로필에 검색 및 지도에 표시됩니다', ], + 'vk_post' => [ + 'label' => '게시물', + 'description' => '미디어를 선택적으로 첨부할 수 있는 텍스트 게시물', + ], ], 'platforms' => [ @@ -727,6 +731,7 @@ 'mastodon_post' => 'Mastodon 게시물', 'telegram_post' => 'Telegram 게시물', 'discord_message' => 'Discord 메시지', + 'vk_post' => 'VK 게시물', 'facebook_post' => 'Facebook 게시물', 'pinterest_pin' => 'Pinterest 핀', 'instagram_story' => 'Instagram 스토리', diff --git a/lang/nl/accounts.php b/lang/nl/accounts.php index 07ddc9a97..7faa4a338 100644 --- a/lang/nl/accounts.php +++ b/lang/nl/accounts.php @@ -42,6 +42,7 @@ 'telegram' => 'Koppel een Telegram-kanaal of -groep', 'discord' => 'Koppel een Discord-server', 'google_business' => 'Koppel een Google Bedrijfsprofiel-locatie', + 'vk' => 'Verbind een VK-community of -profiel', ], 'disconnect_modal' => [ @@ -63,6 +64,22 @@ 'submitting' => 'Koppelen...', ], + 'vk' => [ + 'title' => 'VK verbinden', + 'description' => 'Publiceer in een community of op je eigen prikbord', + 'access_token' => 'Toegangstoken', + 'access_token_placeholder' => 'vk1.a.…', + 'access_token_hint' => 'Plak een VK-gebruikerstoegangstoken met de rechten wall, photos, groups, video, offline. VK geeft nieuwe OAuth-apps het wall-recht niet meer; gebruik een token van een eigen standalone-app (of een dienst zoals vkhost).', + 'pick_target' => 'Waar publiceren', + 'target_group' => 'Community', + 'target_profile' => 'Persoonlijk profiel', + 'invalid_token' => 'VK heeft dit token geweigerd.', + 'invalid_target' => 'Dit prikbord is niet beheerbaar met het opgegeven token.', + 'connection_error' => 'Fout bij verbinden met VK. Probeer het opnieuw.', + 'submit' => 'VK verbinden', + 'submitting' => 'Verbinden...', + ], + 'mastodon' => [ 'title' => 'Mastodon koppelen', 'description' => 'Voer je Mastodon-instance in', diff --git a/lang/nl/posts.php b/lang/nl/posts.php index 3338a4118..f4184e8c4 100644 --- a/lang/nl/posts.php +++ b/lang/nl/posts.php @@ -602,6 +602,10 @@ 'label' => 'Bericht', 'description' => 'Wordt weergegeven in je Bedrijfsprofiel in Zoeken en Kaarten', ], + 'vk_post' => [ + 'label' => 'Bericht', + 'description' => 'Tekstbericht met optionele media', + ], ], 'platforms' => [ @@ -727,6 +731,7 @@ 'mastodon_post' => 'Mastodon-post', 'telegram_post' => 'Telegram-post', 'discord_message' => 'Discord-bericht', + 'vk_post' => 'VK-bericht', 'facebook_post' => 'Facebook-post', 'pinterest_pin' => 'Pinterest-pin', 'instagram_story' => 'Instagram-story', diff --git a/lang/pl/accounts.php b/lang/pl/accounts.php index af434c67e..f4cbe4a6d 100644 --- a/lang/pl/accounts.php +++ b/lang/pl/accounts.php @@ -42,6 +42,7 @@ 'telegram' => 'Połącz kanał lub grupę na Telegramie', 'discord' => 'Połącz serwer Discord', 'google_business' => 'Połącz lokalizację Google Business Profile', + 'vk' => 'Połącz społeczność lub profil VK', ], 'disconnect_modal' => [ @@ -63,6 +64,22 @@ 'submitting' => 'Łączenie...', ], + 'vk' => [ + 'title' => 'Połącz VK', + 'description' => 'Publikuj w społeczności lub na własnej tablicy', + 'access_token' => 'Token dostępu', + 'access_token_placeholder' => 'vk1.a.…', + 'access_token_hint' => 'Wklej token dostępu użytkownika VK z uprawnieniami wall, photos, groups, video, offline. VK nie przyznaje już uprawnienia wall nowym aplikacjom OAuth; użyj tokenu własnej aplikacji standalone (lub serwisu takiego jak vkhost).', + 'pick_target' => 'Gdzie publikować', + 'target_group' => 'Społeczność', + 'target_profile' => 'Profil osobisty', + 'invalid_token' => 'VK odrzucił ten token.', + 'invalid_target' => 'Tą tablicą nie można zarządzać podanym tokenem.', + 'connection_error' => 'Błąd połączenia z VK. Spróbuj ponownie.', + 'submit' => 'Połącz VK', + 'submitting' => 'Łączenie...', + ], + 'mastodon' => [ 'title' => 'Połącz Mastodon', 'description' => 'Wprowadź swoją instancję Mastodon', diff --git a/lang/pl/posts.php b/lang/pl/posts.php index 8deb478fd..008e25915 100644 --- a/lang/pl/posts.php +++ b/lang/pl/posts.php @@ -602,6 +602,10 @@ 'label' => 'Post', 'description' => 'Pojawia się w twoim Profilu Biznesowym w Wyszukiwaniu i Mapach', ], + 'vk_post' => [ + 'label' => 'Post', + 'description' => 'Post tekstowy z opcjonalnymi mediami', + ], ], 'platforms' => [ @@ -727,6 +731,7 @@ 'mastodon_post' => 'Post na Mastodon', 'telegram_post' => 'Post na Telegramie', 'discord_message' => 'Wiadomość na Discord', + 'vk_post' => 'Post VK', 'facebook_post' => 'Post na Facebooku', 'pinterest_pin' => 'Pin na Pinterest', 'instagram_story' => 'Relacja na Instagramie', diff --git a/lang/pt-BR/accounts.php b/lang/pt-BR/accounts.php index eea4987d8..225aae0ef 100644 --- a/lang/pt-BR/accounts.php +++ b/lang/pt-BR/accounts.php @@ -42,6 +42,7 @@ 'telegram' => 'Conecte um canal ou grupo do Telegram', 'discord' => 'Conecte um servidor do Discord', 'google_business' => 'Conecte um local do Google Business Profile', + 'vk' => 'Conecte uma comunidade ou um perfil do VK', ], 'disconnect_modal' => [ @@ -63,6 +64,22 @@ 'submitting' => 'Conectando...', ], + 'vk' => [ + 'title' => 'Conectar VK', + 'description' => 'Publique em uma comunidade ou no seu mural', + 'access_token' => 'Token de acesso', + 'access_token_placeholder' => 'vk1.a.…', + 'access_token_hint' => 'Cole um token de acesso de usuário do VK com os escopos wall, photos, groups, video, offline. O VK não concede mais o escopo wall a novos apps OAuth; use um token de um app standalone seu (ou um serviço como o vkhost).', + 'pick_target' => 'Onde publicar', + 'target_group' => 'Comunidade', + 'target_profile' => 'Perfil pessoal', + 'invalid_token' => 'O VK rejeitou este token.', + 'invalid_target' => 'Este mural não pode ser gerenciado com o token informado.', + 'connection_error' => 'Erro ao conectar ao VK. Tente novamente.', + 'submit' => 'Conectar VK', + 'submitting' => 'Conectando...', + ], + 'mastodon' => [ 'title' => 'Conectar Mastodon', 'description' => 'Digite a instância do seu Mastodon', diff --git a/lang/pt-BR/posts.php b/lang/pt-BR/posts.php index c6d04ee18..5d0c0c311 100644 --- a/lang/pt-BR/posts.php +++ b/lang/pt-BR/posts.php @@ -602,6 +602,10 @@ 'label' => 'Publicação', 'description' => 'Aparece no seu Perfil Empresarial em Pesquisa e Mapas', ], + 'vk_post' => [ + 'label' => 'Publicação', + 'description' => 'Publicação de texto com mídia opcional', + ], ], 'platforms' => [ @@ -727,6 +731,7 @@ 'mastodon_post' => 'Post no Mastodon', 'telegram_post' => 'Post no Telegram', 'discord_message' => 'Mensagem do Discord', + 'vk_post' => 'Publicação do VK', 'facebook_post' => 'Post no Facebook', 'pinterest_pin' => 'Pin no Pinterest', 'instagram_story' => 'Story do Instagram', diff --git a/lang/ru/accounts.php b/lang/ru/accounts.php index 1efee374a..ac2073007 100644 --- a/lang/ru/accounts.php +++ b/lang/ru/accounts.php @@ -42,6 +42,7 @@ 'telegram' => 'Подключите канал или группу Telegram', 'discord' => 'Подключите сервер Discord', 'google_business' => 'Подключите местоположение Google Business Profile', + 'vk' => 'Подключите сообщество или профиль VK', ], 'disconnect_modal' => [ @@ -63,6 +64,22 @@ 'submitting' => 'Подключение...', ], + 'vk' => [ + 'title' => 'Подключить VK', + 'description' => 'Публикация в сообщество или на свою стену', + 'access_token' => 'Ключ доступа', + 'access_token_placeholder' => 'vk1.a.…', + 'access_token_hint' => 'Вставьте пользовательский ключ доступа VK с правами wall, photos, groups, video, offline. Новым OAuth-приложениям VK не выдаёт право wall, поэтому используйте токен своего standalone-приложения (или сервис вроде vkhost).', + 'pick_target' => 'Куда публиковать', + 'target_group' => 'Сообщество', + 'target_profile' => 'Личная страница', + 'invalid_token' => 'VK отклонил этот токен.', + 'invalid_target' => 'Эта стена недоступна для указанного токена.', + 'connection_error' => 'Ошибка подключения к VK. Попробуйте ещё раз.', + 'submit' => 'Подключить VK', + 'submitting' => 'Подключение...', + ], + 'mastodon' => [ 'title' => 'Подключить Mastodon', 'description' => 'Укажите свой сервер Mastodon', diff --git a/lang/ru/posts.php b/lang/ru/posts.php index 4d557f7f4..33a1391bc 100644 --- a/lang/ru/posts.php +++ b/lang/ru/posts.php @@ -602,6 +602,10 @@ 'label' => 'Пост', 'description' => 'Отображается в вашем Профиле компании в Поиске и Картах', ], + 'vk_post' => [ + 'label' => 'Пост', + 'description' => 'Текстовый пост с необязательными медиа', + ], ], 'platforms' => [ @@ -727,6 +731,7 @@ 'mastodon_post' => 'Пост Mastodon', 'telegram_post' => 'Пост Telegram', 'discord_message' => 'Сообщение Discord', + 'vk_post' => 'Пост VK', 'facebook_post' => 'Пост Facebook', 'pinterest_pin' => 'Пин Pinterest', 'instagram_story' => 'История Instagram', diff --git a/lang/tr/accounts.php b/lang/tr/accounts.php index c6e976b5f..4c455418e 100644 --- a/lang/tr/accounts.php +++ b/lang/tr/accounts.php @@ -44,6 +44,7 @@ 'telegram' => 'Bir Telegram kanalı veya grubu bağlayın', 'discord' => 'Bir Discord sunucusu bağlayın', 'google_business' => 'Bir Google İşletme Profili konumu bağlayın', + 'vk' => 'Bir VK topluluğu veya profili bağlayın', ], 'disconnect_modal' => [ @@ -65,6 +66,22 @@ 'submitting' => 'Bağlanıyor...', ], + 'vk' => [ + 'title' => 'VK\'yı bağla', + 'description' => 'Bir topluluğa veya kendi duvarınıza gönderi yayınlayın', + 'access_token' => 'Erişim belirteci', + 'access_token_placeholder' => 'vk1.a.…', + 'access_token_hint' => 'wall, photos, groups, video, offline kapsamlarına sahip bir VK kullanıcı erişim belirteci yapıştırın. VK, yeni OAuth uygulamalarına artık wall kapsamı vermiyor; kendi standalone uygulamanızın belirtecini (veya vkhost gibi bir hizmeti) kullanın.', + 'pick_target' => 'Nerede yayınlansın', + 'target_group' => 'Topluluk', + 'target_profile' => 'Kişisel profil', + 'invalid_token' => 'VK bu belirteci reddetti.', + 'invalid_target' => 'Bu duvar, verilen belirteçle yönetilemiyor.', + 'connection_error' => 'VK\'ya bağlanırken hata oluştu. Lütfen tekrar deneyin.', + 'submit' => 'VK\'yı bağla', + 'submitting' => 'Bağlanıyor...', + ], + 'mastodon' => [ 'title' => 'Mastodon\'u Bağla', 'description' => 'Mastodon sunucunuzu girin', diff --git a/lang/tr/posts.php b/lang/tr/posts.php index b47e9c531..562bfa76e 100644 --- a/lang/tr/posts.php +++ b/lang/tr/posts.php @@ -604,6 +604,10 @@ 'label' => 'Gönderi', 'description' => 'İşletme Profilinde Arama ve Haritalar\'da görünür', ], + 'vk_post' => [ + 'label' => 'Gönderi', + 'description' => 'İsteğe bağlı medya içeren metin gönderisi', + ], ], 'platforms' => [ @@ -729,6 +733,7 @@ 'mastodon_post' => 'Mastodon Gönderisi', 'telegram_post' => 'Telegram Gönderisi', 'discord_message' => 'Discord Mesajı', + 'vk_post' => 'VK Gönderisi', 'facebook_post' => 'Facebook Gönderisi', 'pinterest_pin' => 'Pinterest Pin\'i', 'instagram_story' => 'Instagram Hikayesi', diff --git a/lang/uk/accounts.php b/lang/uk/accounts.php index e3f9402fd..a55689687 100644 --- a/lang/uk/accounts.php +++ b/lang/uk/accounts.php @@ -42,6 +42,7 @@ 'telegram' => 'Підключіть канал або групу Telegram', 'discord' => 'Підключіть сервер Discord', 'google_business' => 'Підключіть місцезнаходження Google Business Profile', + 'vk' => 'Підключіть спільноту або профіль VK', ], 'disconnect_modal' => [ @@ -63,6 +64,22 @@ 'submitting' => 'Підключення...', ], + 'vk' => [ + 'title' => 'Підключити VK', + 'description' => 'Публікація у спільноту або на власну стіну', + 'access_token' => 'Ключ доступу', + 'access_token_placeholder' => 'vk1.a.…', + 'access_token_hint' => 'Вставте користувацький ключ доступу VK із правами wall, photos, groups, video, offline. Новим OAuth-застосункам VK не надає право wall, тому використовуйте токен власного standalone-застосунку (або сервіс на кшталт vkhost).', + 'pick_target' => 'Куди публікувати', + 'target_group' => 'Спільнота', + 'target_profile' => 'Особиста сторінка', + 'invalid_token' => 'VK відхилив цей токен.', + 'invalid_target' => 'Ця стіна недоступна для вказаного токена.', + 'connection_error' => 'Помилка підключення до VK. Спробуйте ще раз.', + 'submit' => 'Підключити VK', + 'submitting' => 'Підключення...', + ], + 'mastodon' => [ 'title' => 'Підключити Mastodon', 'description' => 'Введіть інстанс Mastodon', diff --git a/lang/uk/posts.php b/lang/uk/posts.php index 5fc6a6cd2..40cea193f 100644 --- a/lang/uk/posts.php +++ b/lang/uk/posts.php @@ -602,6 +602,10 @@ 'label' => 'Публікація', 'description' => 'Відображається у вашому Профілі компанії в Пошуку та Картах', ], + 'vk_post' => [ + 'label' => 'Пост', + 'description' => 'Текстовий пост із необов\'язковими медіа', + ], ], 'platforms' => [ @@ -727,6 +731,7 @@ 'mastodon_post' => 'Пост Mastodon', 'telegram_post' => 'Пост Telegram', 'discord_message' => 'Повідомлення Discord', + 'vk_post' => 'Пост VK', 'facebook_post' => 'Пост Facebook', 'pinterest_pin' => 'Pin Pinterest', 'instagram_story' => 'Stories Instagram', diff --git a/lang/zh/accounts.php b/lang/zh/accounts.php index 691f8778d..dec9c3327 100644 --- a/lang/zh/accounts.php +++ b/lang/zh/accounts.php @@ -42,6 +42,7 @@ 'telegram' => '连接一个 Telegram 频道或群组', 'discord' => '连接一个 Discord 服务器', 'google_business' => '连接一个 Google 商家资料位置', + 'vk' => '连接 VK 社群或个人主页', ], 'disconnect_modal' => [ @@ -63,6 +64,22 @@ 'submitting' => '连接中…', ], + 'vk' => [ + 'title' => '连接 VK', + 'description' => '发布到社群或您自己的动态墙', + 'access_token' => '访问令牌', + 'access_token_placeholder' => 'vk1.a.…', + 'access_token_hint' => '粘贴具有 wall, photos, groups, video, offline 权限的 VK 用户访问令牌。VK 不再向新的 OAuth 应用授予 wall 权限,请使用您自己的 standalone 应用令牌(或 vkhost 之类的服务)。', + 'pick_target' => '选择发布位置', + 'target_group' => '社群', + 'target_profile' => '个人主页', + 'invalid_token' => 'VK 拒绝了该令牌。', + 'invalid_target' => '所提供的令牌无法管理此动态墙。', + 'connection_error' => '连接 VK 时出错,请重试。', + 'submit' => '连接 VK', + 'submitting' => '连接中...', + ], + 'mastodon' => [ 'title' => '连接 Mastodon', 'description' => '输入你的 Mastodon 实例', diff --git a/lang/zh/posts.php b/lang/zh/posts.php index 271cfeff9..f5966ad15 100644 --- a/lang/zh/posts.php +++ b/lang/zh/posts.php @@ -602,6 +602,10 @@ 'label' => '帖子', 'description' => '在搜索和地图中显示在您的商业资料中', ], + 'vk_post' => [ + 'label' => '帖子', + 'description' => '可附带媒体的文字帖子', + ], ], 'platforms' => [ @@ -727,6 +731,7 @@ 'mastodon_post' => 'Mastodon 帖子', 'telegram_post' => 'Telegram 帖子', 'discord_message' => 'Discord 消息', + 'vk_post' => 'VK 帖子', 'facebook_post' => 'Facebook 帖子', 'pinterest_pin' => 'Pinterest Pin', 'instagram_story' => 'Instagram 快拍', diff --git a/public/images/accounts/vk.png b/public/images/accounts/vk.png new file mode 100644 index 0000000000000000000000000000000000000000..5491dfcaf7aef69d9a62218405844c82240b10c9 GIT binary patch literal 1987 zcmZ`)2{fDe7XJtpvDDI*sCa2i9aSVU9j%DiQfp=K)P!0SK|~1BCaRWrma1nNT1qKe z`_@`pL`Q3;BbLrkPw5ot(3Vm{wQ4M#FKu7jbKZI1IrqE2|L^|pz2Cj(f6kZYOtMvg zX}|ygP_VZnx`G}nHs~(!tz~|`33O6HmQI!c(3B;&tK4#Z*_jY58quo-43vXe8?Iy{nwG&aN-Vo(Ga5{bk|1_a_(C2sWj`S_hQF8xcVu&7UCfdit&5wwvZ2K~7JMP6~!E$%kdgxTtRM)>Z zh25qv@XMS0-@P^>H z-Mgzv3bWhGyr6c;m>r=`xJdO*{F+xmx(%Eo!CKH1cI@pL6kMMsjPxHsG6)d&c-(D% za5+=?og`c3vX5+NFc4Cw-Phdym&MpC$tCZBs#U8|OmkAIkIG-D@PbRiBLus6DP!OFSq3 zsA!>SE;WWJb10xbK{S_JAFv(4on12@Zcsj+IO3YDua{AVzE*p+vt&LmnY7ZuKbYHe zuROc6n}AwrC9TghXdwdG6OC<|dQ=-EZh?m@J&R(^Nr{$7FX6h@$!aGcdEPeL8;cF$ z6J>`7yB#)6P07hS#{KzTP<$&?cfT#a{=&;25UG7pN-}U>k^kx>CO2yMoW{kmopa_Q z$5LRqq*`X_4(!;IUc&FNCv54P&uuI$#@^c@w{2D`P3hUuZbfTa1EKk~x!PF)?Fze3 zZ5F4Ur!%jQ*k5&;Q}N_n~G3ePiNO!u^H8!dvi=jG_B2hF1MWIR>8e zHdXm6Loq|m#~&u7(fE})1L&hRlbk)lraeJpM2jHd{Ij0giu;b?VKQ3Yzxf=n37pPV z-c3{c9s@&oM8nKjLkIEM@QQELVw+R_JfMrtHK_4fN=b%R%J6GnC8^$_@1Vt(5m1Bf zjHmy(D{@d8ee-RCqepRLJ+`BThkVFkcw4BFn|wS478jhkI$k@ZlmBy9 z!N%#VPX6_C_CmpE$-l~DaDqS8@?AMQuPU1%7uI_^<=b4{bx$7R{XUMBN-1;0O^I5) zmbL!;BPJcn4N|%7`OHkxDA88O@)eG7o1`I)Ts!~B46@bdi2CDKKIv)2@^_|`4a2t# zegoOKsXcL?ac__OOvOMNYk6L+=iHHc45E8j9eVR};6|i&Gy<#pMZXNMD}xig{yZN zk(yX09L^h;dyv0yGcNXypXiM~uyLcKPWWS@t)@q*l4qv;m6q{D+eqW$>lxBn$FQLf kC~{hY33jIOPB?xQoiLrIM2RpuCjQ^qA14tTt^5-I290k?4gdfE literal 0 HcmV?d00001 diff --git a/resources/js/components/posts/previews/PlatformPreview.vue b/resources/js/components/posts/previews/PlatformPreview.vue index 490528c69..b7c3f3a5f 100644 --- a/resources/js/components/posts/previews/PlatformPreview.vue +++ b/resources/js/components/posts/previews/PlatformPreview.vue @@ -16,6 +16,7 @@ import PinterestPreview from './PinterestPreview.vue'; import TelegramPreview from './TelegramPreview.vue'; import ThreadsPreview from './ThreadsPreview.vue'; import TikTokPreview from './TikTokPreview.vue'; +import VkPreview from './VkPreview.vue'; import XPreview from './XPreview.vue'; import YouTubePreview from './YouTubePreview.vue'; @@ -91,6 +92,8 @@ const previewComponent = computed(() => { return DiscordPreview; case 'google_business': return GoogleBusinessPreview; + case 'vk': + return VkPreview; default: return LinkedInPreview; } diff --git a/resources/js/components/posts/previews/VkPreview.vue b/resources/js/components/posts/previews/VkPreview.vue new file mode 100644 index 000000000..2c50171de --- /dev/null +++ b/resources/js/components/posts/previews/VkPreview.vue @@ -0,0 +1,133 @@ + + + diff --git a/resources/js/composables/useOAuthPopup.ts b/resources/js/composables/useOAuthPopup.ts index 350c1b36c..623e8dd79 100644 --- a/resources/js/composables/useOAuthPopup.ts +++ b/resources/js/composables/useOAuthPopup.ts @@ -14,6 +14,7 @@ import { connect as pinterestConnect } from '@/routes/app/social/pinterest'; import { connect as threadsConnect } from '@/routes/app/social/threads'; import { connect as tiktokConnect } from '@/routes/app/social/tiktok'; import { connect as xConnect } from '@/routes/app/social/x'; +import { connect as vkConnect } from '@/routes/app/social/vk'; import { connect as youtubeConnect } from '@/routes/app/social/youtube'; import { Platform } from '@/types/platform'; @@ -34,6 +35,7 @@ const CONNECT_ROUTES: Record = { telegram: '/images/accounts/telegram.png', discord: '/images/accounts/discord.png', google_business: '/images/accounts/google_business.png', + vk: '/images/accounts/vk.png', }; const PLATFORM_LABELS: Record = { @@ -32,6 +33,7 @@ const PLATFORM_LABELS: Record = { telegram: 'Telegram', discord: 'Discord', google_business: 'Google Business Profile', + vk: 'VK', }; const PLATFORM_CONTENT_TYPES: Record = { @@ -54,6 +56,7 @@ const PLATFORM_CONTENT_TYPES: Record = { telegram: ['telegram_post'], discord: ['discord_message'], google_business: ['google_business_post'], + vk: ['vk_post'], }; export interface ContentTypeOption { @@ -77,6 +80,7 @@ const PLATFORM_THEMES: Record = { telegram: { bg: 'bg-sky-200', rotate: '-rotate-2' }, discord: { bg: 'bg-indigo-200', rotate: 'rotate-1' }, google_business: { bg: 'bg-blue-100', rotate: 'rotate-2' }, + vk: { bg: 'bg-blue-200', rotate: '-rotate-1' }, }; export const getPlatformLogo = (platform: string): string => diff --git a/resources/js/pages/accounts/VkConnect.vue b/resources/js/pages/accounts/VkConnect.vue new file mode 100644 index 000000000..018e3f0e5 --- /dev/null +++ b/resources/js/pages/accounts/VkConnect.vue @@ -0,0 +1,113 @@ + + + diff --git a/resources/js/types/content-type.ts b/resources/js/types/content-type.ts index 43eea690a..9f0fb49c2 100644 --- a/resources/js/types/content-type.ts +++ b/resources/js/types/content-type.ts @@ -20,6 +20,7 @@ export const ContentType = { TelegramPost: 'telegram_post', DiscordMessage: 'discord_message', GoogleBusinessPost: 'google_business_post', + VkPost: 'vk_post', } as const; export type ContentTypeValue = (typeof ContentType)[keyof typeof ContentType]; diff --git a/resources/js/types/platform.ts b/resources/js/types/platform.ts index d0744d1a4..67e4031b5 100644 --- a/resources/js/types/platform.ts +++ b/resources/js/types/platform.ts @@ -14,6 +14,7 @@ export const Platform = { Telegram: 'telegram', Discord: 'discord', GoogleBusiness: 'google_business', + Vk: 'vk', } as const; export type PlatformValue = (typeof Platform)[keyof typeof Platform]; diff --git a/routes/app.php b/routes/app.php index 8ffcbdff7..fe82f951a 100644 --- a/routes/app.php +++ b/routes/app.php @@ -32,6 +32,7 @@ use App\Http\Controllers\App\WorkspaceLabelController; use App\Http\Controllers\App\WorkspaceSignatureController; use App\Http\Controllers\Auth\BlueskyController; +use App\Http\Controllers\Auth\VkController; use App\Http\Controllers\Auth\DiscordController; use App\Http\Controllers\Auth\FacebookController; use App\Http\Controllers\Auth\GoogleBusinessController; @@ -110,6 +111,8 @@ Route::get('connect/pinterest', [PinterestController::class, 'connect'])->name('app.social.pinterest.connect'); Route::get('connect/bluesky', [BlueskyController::class, 'connect'])->name('app.social.bluesky.connect'); Route::post('connect/bluesky', [BlueskyController::class, 'store'])->name('app.social.bluesky.store'); + Route::get('connect/vk', [VkController::class, 'connect'])->name('app.social.vk.connect'); + Route::post('connect/vk', [VkController::class, 'store'])->name('app.social.vk.store'); Route::get('connect/mastodon', [MastodonController::class, 'connect'])->name('app.social.mastodon.connect'); Route::post('connect/mastodon', [MastodonController::class, 'authorizeInstance'])->name('app.social.mastodon.authorize'); Route::post('connect/telegram', [TelegramController::class, 'connect'])->name('app.social.telegram.connect'); diff --git a/tests/Feature/Services/Social/VkPublisherTest.php b/tests/Feature/Services/Social/VkPublisherTest.php new file mode 100644 index 000000000..e6dc771f0 --- /dev/null +++ b/tests/Feature/Services/Social/VkPublisherTest.php @@ -0,0 +1,127 @@ +user = User::factory()->create(); + $this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]); + + $this->socialAccount = SocialAccount::factory()->vk()->create([ + 'workspace_id' => $this->workspace->id, + 'username' => 'testcommunity', + ]); + + $this->post = Post::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'user_id' => $this->user->id, + 'content' => 'Hello from VK!', + ]); + + $this->postPlatform = PostPlatform::factory()->create([ + 'post_id' => $this->post->id, + 'social_account_id' => $this->socialAccount->id, + 'platform' => Platform::Vk, + 'content_type' => ContentType::VkPost, + ]); + + $this->publisher = new VkPublisher; + $this->api = rtrim((string) config('trypost.platforms.vk.api'), '/'); +}); + +test('vk publisher can publish text-only post to a community', function () { + Http::fake([ + "{$this->api}/wall.post*" => Http::response([ + 'response' => ['post_id' => 42], + ], 200), + ]); + + $result = $this->publisher->publish($this->postPlatform); + + expect($result['id'])->toBe('42') + ->and($result['url'])->toBe('https://vk.com/wall-123456_42'); + + Http::assertSent(function ($request) { + return str_contains($request->url(), '/wall.post') + && $request['owner_id'] == -123456 + && $request['from_group'] == 1 + && $request['message'] === 'Hello from VK!' + && $request['v'] === config('trypost.platforms.vk.api_version'); + }); +}); + +test('vk publisher posts to a profile wall without from_group', function () { + $this->socialAccount->update([ + 'platform_user_id' => '111', + 'meta' => ['owner_id' => 111, 'is_group' => false, 'vk_user_id' => 111], + ]); + $this->postPlatform->refresh(); + + Http::fake([ + "{$this->api}/wall.post*" => Http::response([ + 'response' => ['post_id' => 7], + ], 200), + ]); + + $result = $this->publisher->publish($this->postPlatform); + + expect($result['url'])->toBe('https://vk.com/wall111_7'); + + Http::assertSent(function ($request) { + return str_contains($request->url(), '/wall.post') + && $request['owner_id'] == 111 + && ! isset($request['from_group']); + }); +}); + +test('vk publisher throws token expired exception on dead token', function () { + Http::fake([ + "{$this->api}/wall.post*" => Http::response([ + 'error' => [ + 'error_code' => 5, + 'error_msg' => 'User authorization failed: invalid access_token.', + ], + ], 200), + ]); + + $this->publisher->publish($this->postPlatform); +})->throws(TokenExpiredException::class); + +test('vk publisher throws publish exception on api error', function () { + Http::fake([ + "{$this->api}/wall.post*" => Http::response([ + 'error' => [ + 'error_code' => 214, + 'error_msg' => 'Access to adding post denied.', + ], + ], 200), + ]); + + try { + $this->publisher->publish($this->postPlatform); + $this->fail('Expected VkPublishException'); + } catch (VkPublishException $e) { + expect($e->platformErrorCode)->toBe('214') + ->and($e->platform())->toBe('vk'); + } +}); + +test('vk publisher rejects content over the platform limit', function () { + $this->post->update(['content' => str_repeat('a', Platform::Vk->maxContentLength() + 1)]); + $this->postPlatform->refresh(); + + Http::fake(); + + $this->publisher->publish($this->postPlatform); +})->throws(Exception::class, 'Content exceeds VK limit'); diff --git a/tests/Feature/Social/VkControllerTest.php b/tests/Feature/Social/VkControllerTest.php new file mode 100644 index 000000000..711e27b1b --- /dev/null +++ b/tests/Feature/Social/VkControllerTest.php @@ -0,0 +1,133 @@ +user = User::factory()->create(); + $this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]); + $this->user->update(['current_workspace_id' => $this->workspace->id]); + $this->workspace->members()->attach($this->user->id, ['role' => Role::Member->value]); + + $this->api = rtrim((string) config('trypost.platforms.vk.api'), '/'); +}); + +function fakeVkIdentity(string $api): array +{ + return [ + "{$api}/users.get*" => Http::response([ + 'response' => [ + [ + 'id' => 111, + 'first_name' => 'Test', + 'last_name' => 'User', + 'screen_name' => 'testuser', + 'photo_200' => null, + ], + ], + ], 200), + "{$api}/groups.get*" => Http::response([ + 'response' => [ + 'count' => 1, + 'items' => [ + [ + 'id' => 123456, + 'name' => 'Test Community', + 'screen_name' => 'testcommunity', + 'photo_200' => null, + ], + ], + ], + ], 200), + ]; +} + +test('vk connect page can be rendered', function () { + $response = $this->actingAs($this->user)->get(route('app.social.vk.connect')); + + $response->assertOk(); + $response->assertInertia(fn (AssertableInertia $page) => $page->component('accounts/VkConnect')); +}); + +test('submitting a valid token lists manageable walls', function () { + Http::fake(fakeVkIdentity($this->api)); + + $response = $this->actingAs($this->user)->post(route('app.social.vk.store'), [ + 'access_token' => 'vk1.a.valid-test-token', + ]); + + $response->assertOk(); + $response->assertInertia(fn (AssertableInertia $page) => $page + ->component('accounts/VkConnect') + ->has('targets', 2) + ->where('targets.0.owner_id', 111) + ->where('targets.1.owner_id', -123456) + ->where('targets.1.is_group', true)); + + $this->assertDatabaseMissing('social_accounts', [ + 'workspace_id' => $this->workspace->id, + 'platform' => Platform::Vk->value, + ]); +}); + +test('user can connect a vk community wall', function () { + Http::fake(fakeVkIdentity($this->api)); + + $response = $this->actingAs($this->user)->post(route('app.social.vk.store'), [ + 'access_token' => 'vk1.a.valid-test-token', + 'owner_id' => -123456, + ]); + + $response->assertOk(); + $response->assertInertia(fn (AssertableInertia $page) => $page->component('accounts/PopupCallback')); + $response->assertInertia(fn (AssertableInertia $page) => $page->where('success', true)); + + $this->assertDatabaseHas('social_accounts', [ + 'workspace_id' => $this->workspace->id, + 'platform' => Platform::Vk->value, + 'platform_user_id' => '-123456', + 'username' => 'testcommunity', + 'display_name' => 'Test Community', + 'status' => Status::Connected->value, + ]); +}); + +test('connecting a wall the token does not manage is rejected', function () { + Http::fake(fakeVkIdentity($this->api)); + + $response = $this->actingAs($this->user)->post(route('app.social.vk.store'), [ + 'access_token' => 'vk1.a.valid-test-token', + 'owner_id' => -999999, + ]); + + $response->assertSessionHasErrors('owner_id'); + + $this->assertDatabaseMissing('social_accounts', [ + 'workspace_id' => $this->workspace->id, + 'platform' => Platform::Vk->value, + ]); +}); + +test('a token vk rejects surfaces the api error on the token field', function () { + Http::fake([ + "{$this->api}/users.get*" => Http::response([ + 'error' => [ + 'error_code' => 5, + 'error_msg' => 'User authorization failed: invalid access_token.', + ], + ], 200), + ]); + + $response = $this->actingAs($this->user)->post(route('app.social.vk.store'), [ + 'access_token' => 'vk1.a.revoked-token', + ]); + + $response->assertSessionHasErrors('access_token'); +}); diff --git a/tests/Unit/Enums/PlatformTest.php b/tests/Unit/Enums/PlatformTest.php index 7f0bb590f..3a79942d8 100644 --- a/tests/Unit/Enums/PlatformTest.php +++ b/tests/Unit/Enums/PlatformTest.php @@ -19,6 +19,7 @@ expect(Platform::Pinterest->label())->toBe('Pinterest'); expect(Platform::Bluesky->label())->toBe('Bluesky'); expect(Platform::Mastodon->label())->toBe('Mastodon'); + expect(Platform::Vk->label())->toBe('VK'); }); test('platform has correct colors', function () { @@ -33,6 +34,7 @@ expect(Platform::Pinterest->color())->toBe('#E60023'); expect(Platform::Bluesky->color())->toBe('#0085FF'); expect(Platform::Mastodon->color())->toBe('#6364FF'); + expect(Platform::Vk->color())->toBe('#0077FF'); }); test('platform has correct allowed media types', function () { diff --git a/tests/Unit/Exceptions/Social/VkPublishExceptionTest.php b/tests/Unit/Exceptions/Social/VkPublishExceptionTest.php new file mode 100644 index 000000000..5a2c9eeab --- /dev/null +++ b/tests/Unit/Exceptions/Social/VkPublishExceptionTest.php @@ -0,0 +1,50 @@ + ['error_code' => $code, 'error_msg' => $msg]], $status); + + return Http::fake(['*' => $response])->post('https://vk.example/method/wall.post'); +} + +test('error 5 (authorization failed) throws TokenExpiredException', function () { + VkPublishException::fromApiResponse(fakeVkErrorResponse(5, 'User authorization failed.')); +})->throws(TokenExpiredException::class); + +test('error 6 (too many requests) maps to RateLimit category', function () { + $exception = VkPublishException::fromApiResponse(fakeVkErrorResponse(6, 'Too many requests per second.')); + + expect($exception->category)->toBe(ErrorCategory::RateLimit) + ->and($exception->platformErrorCode)->toBe('6'); +}); + +test('error 214 (post access denied) maps to Permission category', function () { + $exception = VkPublishException::fromApiResponse(fakeVkErrorResponse(214, 'Access to adding post denied.')); + + expect($exception->category)->toBe(ErrorCategory::Permission) + ->and($exception->userMessage)->toBe('Access to adding post denied.'); +}); + +test('unknown error code maps to Unknown category with vk platform', function () { + $exception = VkPublishException::fromApiResponse(fakeVkErrorResponse(1, 'Unknown error occurred.')); + + expect($exception->category)->toBe(ErrorCategory::Unknown) + ->and($exception->platform())->toBe('vk'); +}); + +test('transport 5xx without vk error object maps to ServerError', function () { + $response = Http::response('Bad gateway', 502); + $fakeResponse = Http::fake(['*' => $response])->post('https://vk.example/method/wall.post'); + + $exception = VkPublishException::fromApiResponse($fakeResponse); + + expect($exception->category)->toBe(ErrorCategory::ServerError) + ->and($exception->platformErrorCode)->toBe('502'); +}); From 9286f52fb1dc699e5f9cda7bb48299bdd4b4877d Mon Sep 17 00:00:00 2001 From: Grigory Frolov <2168057+gynsus@users.noreply.github.com> Date: Mon, 17 Aug 2026 10:49:17 +0300 Subject: [PATCH 2/8] fix: full-bleed VK account icon --- public/images/accounts/vk.png | Bin 1987 -> 570 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/public/images/accounts/vk.png b/public/images/accounts/vk.png index 5491dfcaf7aef69d9a62218405844c82240b10c9..3ad605a5dcb453ad68b26023bd08745950fa49d1 100644 GIT binary patch delta 546 zcmV+-0^R+?54r@9BYy$`Nkl((!q}EPpjYQRRDz03Q|ETNCjEx)dT0#w@`c!wQU*a&+mK@e4ZMs>*H(PAq{%!80_UU3PR%{2_h7P z1}vOWpWA|!g6PDR4UYzLaedf1>Riu!K$0M07DSonrQE!TdVHz^JGGq+M8?rcK}>Pr ziXR(DbX=r>=rWVeNt)4IQ!5!JrV(9Je4O$L_9U2?Mt^kmrlMQ*DF|Xpmx5)?o$T$G z#)NL|uz{eAtfpJlqcT`52+dNizi8+yN-#_;i0+lemP%t@8iQa`G6kLEbfxzNF~PXD z3r7QS?J+`aOtK4#Z*_jY58quo-43vXe8?Iy{nwG&aN-Vo(Ga5{bk|1_a_(C2sWj`S_hQF8xcVu&7UCfdit&5wwvZ2K~7JMP6~!E$%kdgxTtRM)>Z zh25qv@XMS0-@P^>H z-Mgzv3bWhGyr6c;m>r=`xJdO*{F+xmx(%Eo!CKH1cI@pL6kMMsjPxHsG6)d&c-(D% za5+=?og`c3vX5+NFc4Cw-Phdym&MpC$tCZBs#U8|OmkAIkIG-D@PbRiBLus6DP!OFSq3 zsA!>SE;WWJb10xbK{S_JAFv(4on12@Zcsj+IO3YDua{AVzE*p+vt&LmnY7ZuKbYHe zuROc6n}AwrC9TghXdwdG6OC<|dQ=-EZh?m@J&R(^Nr{$7FX6h@$!aGcdEPeL8;cF$ z6J>`7yB#)6P07hS#{KzTP<$&?cfT#a{=&;25UG7pN-}U>k^kx>CO2yMoW{kmopa_Q z$5LRqq*`X_4(!;IUc&FNCv54P&uuI$#@^c@w{2D`P3hUuZbfTa1EKk~x!PF)?Fze3 zZ5F4Ur!%jQ*k5&;Q}N_n~G3ePiNO!u^H8!dvi=jG_B2hF1MWIR>8e zHdXm6Loq|m#~&u7(fE})1L&hRlbk)lraeJpM2jHd{Ij0giu;b?VKQ3Yzxf=n37pPV z-c3{c9s@&oM8nKjLkIEM@QQELVw+R_JfMrtHK_4fN=b%R%J6GnC8^$_@1Vt(5m1Bf zjHmy(D{@d8ee-RCqepRLJ+`BThkVFkcw4BFn|wS478jhkI$k@ZlmBy9 z!N%#VPX6_C_CmpE$-l~DaDqS8@?AMQuPU1%7uI_^<=b4{bx$7R{XUMBN-1;0O^I5) zmbL!;BPJcn4N|%7`OHkxDA88O@)eG7o1`I)Ts!~B46@bdi2CDKKIv)2@^_|`4a2t# zegoOKsXcL?ac__OOvOMNYk6L+=iHHc45E8j9eVR};6|i&Gy<#pMZXNMD}xig{yZN zk(yX09L^h;dyv0yGcNXypXiM~uyLcKPWWS@t)@q*l4qv;m6q{D+eqW$>lxBn$FQLf kC~{hY33jIOPB?xQoiLrIM2RpuCjQ^qA14tTt^5-I290k?4gdfE From d343a3950fefcd4f59e5dee104cbd2b9f1a85b6a Mon Sep 17 00:00:00 2001 From: Grigory Frolov <2168057+gynsus@users.noreply.github.com> Date: Sat, 22 Aug 2026 11:55:24 +0300 Subject: [PATCH 3/8] =?UTF-8?q?feat:=20VK=20analytics=20=E2=80=94=20commun?= =?UTF-8?q?ity=20members=20and=20per-post=20wall=20metrics?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit VK joins the analytics page: account level shows the community member count (profile wall: follower count) via groups.getById/users.get; post level pulls views, likes, reposts and comments from wall.getById. Deeper community stats need the stats scope the connect flow doesn't request, so they are deliberately out. --- .../Controllers/App/AnalyticsController.php | 3 + app/Services/Post/PostMetricsFetcher.php | 2 + app/Services/Social/Vk/VkAnalytics.php | 104 ++++++++++++++++++ .../js/components/analytics/VkAnalytics.vue | 57 ++++++++++ resources/js/pages/analytics/Index.vue | 6 + .../Services/Social/VkAnalyticsTest.php | 77 +++++++++++++ 6 files changed, 249 insertions(+) create mode 100644 app/Services/Social/Vk/VkAnalytics.php create mode 100644 resources/js/components/analytics/VkAnalytics.vue create mode 100644 tests/Feature/Services/Social/VkAnalyticsTest.php diff --git a/app/Http/Controllers/App/AnalyticsController.php b/app/Http/Controllers/App/AnalyticsController.php index a764e2796..b5a406163 100644 --- a/app/Http/Controllers/App/AnalyticsController.php +++ b/app/Http/Controllers/App/AnalyticsController.php @@ -14,6 +14,7 @@ use App\Services\Social\LinkedInPageAnalytics; use App\Services\Social\PinterestAnalytics; use App\Services\Social\Telegram\TelegramAnalytics; +use App\Services\Social\Vk\VkAnalytics; use App\Services\Social\ThreadsAnalytics; use App\Services\Social\TikTokAnalytics; use App\Services\Social\XAnalytics; @@ -40,6 +41,7 @@ class AnalyticsController extends Controller Platform::YouTube, Platform::Telegram, Platform::GoogleBusiness, + Platform::Vk, ]; public function index(Request $request): Response @@ -125,6 +127,7 @@ private function metricsFor(SocialAccount $account, ?Carbon $since, ?Carbon $unt Platform::YouTube => app(YouTubeAnalytics::class)->getMetrics($account, $since, $until), Platform::Telegram => app(TelegramAnalytics::class)->getMetrics($account), Platform::GoogleBusiness => app(GoogleBusinessAnalytics::class)->getMetrics($account, $since, $until), + Platform::Vk => app(VkAnalytics::class)->getMetrics($account), default => [], }; } catch (PlatformUnavailableException|ConnectionException $e) { diff --git a/app/Services/Post/PostMetricsFetcher.php b/app/Services/Post/PostMetricsFetcher.php index dec6438a9..2f289f0ac 100644 --- a/app/Services/Post/PostMetricsFetcher.php +++ b/app/Services/Post/PostMetricsFetcher.php @@ -16,6 +16,7 @@ use App\Services\Social\MastodonAnalytics; use App\Services\Social\PinterestAnalytics; use App\Services\Social\Telegram\TelegramAnalytics; +use App\Services\Social\Vk\VkAnalytics; use App\Services\Social\ThreadsAnalytics; use App\Services\Social\TikTokAnalytics; use App\Services\Social\XAnalytics; @@ -73,6 +74,7 @@ public function forPlatform(PostPlatform $postPlatform): array Platform::Bluesky => app(BlueskyAnalytics::class)->fetchPostMetrics($postPlatform), Platform::Mastodon => app(MastodonAnalytics::class)->fetchPostMetrics($postPlatform), Platform::Telegram => app(TelegramAnalytics::class)->fetchPostMetrics($postPlatform), + Platform::Vk => app(VkAnalytics::class)->fetchPostMetrics($postPlatform), Platform::Discord => app(DiscordAnalytics::class)->fetchPostMetrics($postPlatform), Platform::Instagram, Platform::InstagramFacebook => app(InstagramAnalytics::class)->fetchPostMetrics($postPlatform), Platform::Facebook => app(FacebookAnalytics::class)->fetchPostMetrics($postPlatform), diff --git a/app/Services/Social/Vk/VkAnalytics.php b/app/Services/Social/Vk/VkAnalytics.php new file mode 100644 index 000000000..136eaa06e --- /dev/null +++ b/app/Services/Social/Vk/VkAnalytics.php @@ -0,0 +1,104 @@ + + */ + public function getMetrics(SocialAccount $account): array + { + $ownerId = (int) (data_get($account->meta, 'owner_id') ?? $account->platform_user_id); + + try { + if ($ownerId < 0) { + $response = Http::asForm()->post(VkApi::endpoint('groups.getById'), [ + 'group_id' => abs($ownerId), + 'fields' => 'members_count', + ] + VkApi::baseParams($account->access_token))->json(); + + // v5.199 отдаёт response.groups[], более старые версии — response[]. + $count = data_get($response, 'response.groups.0.members_count') + ?? data_get($response, 'response.0.members_count'); + } else { + $response = Http::asForm()->post(VkApi::endpoint('users.get'), [ + 'user_ids' => $ownerId, + 'fields' => 'followers_count', + ] + VkApi::baseParams($account->access_token))->json(); + + $count = data_get($response, 'response.0.followers_count'); + } + } catch (Throwable) { + return []; + } + + if (! is_int($count)) { + return []; + } + + return [ + ['label' => __('analytics.metrics.subscribers'), 'value' => $count], + ]; + } + + /** + * Post-level metrics from wall.getById: views, likes, reposts, comments. + * + * @return array + */ + public function fetchPostMetrics(PostPlatform $postPlatform): array + { + $account = $postPlatform->socialAccount; + $postId = $postPlatform->platform_post_id; + + if (! $account || ! $postId) { + return []; + } + + $ownerId = (int) (data_get($account->meta, 'owner_id') ?? $account->platform_user_id); + + try { + $response = Http::asForm()->post(VkApi::endpoint('wall.getById'), [ + 'posts' => "{$ownerId}_{$postId}", + ] + VkApi::baseParams($account->access_token))->json(); + } catch (Throwable) { + return []; + } + + // v5.199 отдаёт response.items[], более старые версии — response[]. + $post = data_get($response, 'response.items.0') ?? data_get($response, 'response.0'); + + if (! is_array($post)) { + return []; + } + + $metrics = []; + + foreach ([ + 'views.count' => 'analytics.metrics.views', + 'likes.count' => 'analytics.metrics.likes', + 'reposts.count' => 'analytics.metrics.reposts', + 'comments.count' => 'analytics.metrics.comments', + ] as $path => $labelKey) { + $value = data_get($post, $path); + + if (is_int($value)) { + $metrics[] = ['label' => __($labelKey), 'value' => $value]; + } + } + + return $metrics; + } +} diff --git a/resources/js/components/analytics/VkAnalytics.vue b/resources/js/components/analytics/VkAnalytics.vue new file mode 100644 index 000000000..44994bffb --- /dev/null +++ b/resources/js/components/analytics/VkAnalytics.vue @@ -0,0 +1,57 @@ + + + diff --git a/resources/js/pages/analytics/Index.vue b/resources/js/pages/analytics/Index.vue index 917fe7587..89f9f73fc 100644 --- a/resources/js/pages/analytics/Index.vue +++ b/resources/js/pages/analytics/Index.vue @@ -15,6 +15,7 @@ import TikTokAnalytics from '@/components/analytics/TikTokAnalytics.vue'; import type { AnalyticsAccount } from '@/components/analytics/types'; import XAnalytics from '@/components/analytics/XAnalytics.vue'; import YouTubeAnalytics from '@/components/analytics/YouTubeAnalytics.vue'; +import VkAnalytics from '@/components/analytics/VkAnalytics.vue'; import PageHeader from '@/components/PageHeader.vue'; import { DateRangePicker } from '@/components/ui/date-range-picker'; import dayjs from '@/dayjs'; @@ -149,6 +150,11 @@ const platformSupportsDateRange = computed(() => { :account-id="selectedAccountId" /> + +
user = User::factory()->create(); + $this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]); + $this->account = SocialAccount::factory()->vk()->create(['workspace_id' => $this->workspace->id]); + $this->analytics = new VkAnalytics; + $this->api = rtrim((string) config('trypost.platforms.vk.api'), '/'); +}); + +test('vk analytics returns the community member count', function () { + Http::fake([ + "{$this->api}/groups.getById*" => Http::response([ + 'response' => ['groups' => [['id' => 123456, 'members_count' => 4321]]], + ], 200), + ]); + + $metrics = $this->analytics->getMetrics($this->account); + + expect($metrics)->toHaveCount(1) + ->and($metrics[0]['value'])->toBe(4321); +}); + +test('vk analytics returns post views, likes, reposts and comments', function () { + $post = Post::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'user_id' => $this->user->id, + 'content' => 'x', + ]); + $row = PostPlatform::factory()->create([ + 'post_id' => $post->id, + 'social_account_id' => $this->account->id, + 'platform' => Platform::Vk, + 'content_type' => ContentType::VkPost, + 'platform_post_id' => '42', + ]); + + Http::fake([ + "{$this->api}/wall.getById*" => Http::response([ + 'response' => ['items' => [[ + 'views' => ['count' => 100], + 'likes' => ['count' => 10], + 'reposts' => ['count' => 3], + 'comments' => ['count' => 5], + ]]], + ], 200), + ]); + + $metrics = $this->analytics->fetchPostMetrics($row); + + expect(array_column($metrics, 'value'))->toBe([100, 10, 3, 5]); + + Http::assertSent(fn ($request) => str_contains($request->url(), '/wall.getById') + && $request['posts'] === '-123456_42'); +}); + +test('vk analytics returns empty metrics on api error', function () { + Http::fake([ + "{$this->api}/groups.getById*" => Http::response([ + 'error' => ['error_code' => 5, 'error_msg' => 'auth failed'], + ], 200), + ]); + + expect($this->analytics->getMetrics($this->account))->toBe([]); +}); From d0857ee1a8e47095ac3461aba279a7857b8bc495 Mon Sep 17 00:00:00 2001 From: Grigory Frolov <2168057+gynsus@users.noreply.github.com> Date: Sun, 30 Aug 2026 14:46:54 +0300 Subject: [PATCH 4/8] feat: connect VK with a community access token wall.post with a user token is limited to standalone apps; community access tokens publish to their community regardless of the issuing app type. users.get error 27 tells the two apart on the shared token field; verification for community-token accounts goes through groups.getById. --- app/Http/Controllers/Auth/VkController.php | 106 +++++++++++++++++- app/Services/Social/ConnectionVerifier.php | 11 +- lang/ar/accounts.php | 2 +- lang/de/accounts.php | 2 +- lang/el/accounts.php | 2 +- lang/en/accounts.php | 2 +- lang/es/accounts.php | 2 +- lang/fr/accounts.php | 2 +- lang/it/accounts.php | 2 +- lang/ja/accounts.php | 2 +- lang/ko/accounts.php | 2 +- lang/nl/accounts.php | 2 +- lang/pl/accounts.php | 2 +- lang/pt-BR/accounts.php | 2 +- lang/ru/accounts.php | 2 +- lang/tr/accounts.php | 2 +- lang/uk/accounts.php | 2 +- lang/zh/accounts.php | 2 +- .../Social/ConnectionVerifierTest.php | 43 +++++++ tests/Feature/Social/VkControllerTest.php | 75 +++++++++++++ 20 files changed, 243 insertions(+), 24 deletions(-) diff --git a/app/Http/Controllers/Auth/VkController.php b/app/Http/Controllers/Auth/VkController.php index 2fac967c3..71eb23977 100644 --- a/app/Http/Controllers/Auth/VkController.php +++ b/app/Http/Controllers/Auth/VkController.php @@ -6,6 +6,7 @@ use App\Enums\SocialAccount\Platform as SocialPlatform; use App\Enums\SocialAccount\Status; +use App\Models\Workspace; use App\Services\Social\Vk\VkApi; use Illuminate\Http\Request; use Illuminate\Support\Facades\Http; @@ -24,6 +25,13 @@ */ class VkController extends SocialController { + /** + * VK error 27: "Group authorization failed" — the method is unavailable + * with a community access token. Used to tell community tokens apart from + * user tokens on the shared token field. + */ + private const int VK_ERROR_GROUP_AUTH = 27; + protected SocialPlatform $platform = SocialPlatform::Vk; public function connect(Request $request): InertiaResponse @@ -53,12 +61,13 @@ public function store(Request $request): InertiaResponse $this->authorize('manageAccounts', $workspace); try { - $user = $this->callVk($request->access_token, 'users.get', [ - 'fields' => 'screen_name,photo_200', - ])[0] ?? null; + $user = $this->fetchTokenUser($request->access_token); - if (! is_array($user)) { - throw ValidationException::withMessages(['access_token' => __('accounts.vk.invalid_token')]); + if ($user === null) { + // Community access token: it maps to exactly one community and + // wall.post with it is allowed regardless of the app type that + // issued it — connect that community directly, no second step. + return $this->storeCommunityAccount($request, $workspace); } $targets = $this->buildTargets($request->access_token, $user); @@ -115,6 +124,93 @@ public function store(Request $request): InertiaResponse } } + /** + * The user behind a user access token, or null when the token is a + * community access token (users.get answers with error 27 for those). + * Any other VK error surfaces as a validation error on the token field. + * + * @return array|null + */ + private function fetchTokenUser(string $accessToken): ?array + { + $response = Http::asForm()->post(VkApi::endpoint('users.get'), [ + 'fields' => 'screen_name,photo_200', + ] + VkApi::baseParams($accessToken)); + + if ((int) $response->json('error.error_code') === self::VK_ERROR_GROUP_AUTH) { + return null; + } + + $error = $response->json('error'); + + if ($response->failed() || $error !== null) { + Log::error('VK connect API call failed', [ + 'method' => 'users.get', + 'status' => $response->status(), + 'error_code' => data_get($error, 'error_code'), + ]); + + throw ValidationException::withMessages([ + 'access_token' => data_get($error, 'error_msg') ?: __('accounts.vk.connection_error'), + ]); + } + + $user = $response->json('response.0'); + + if (! is_array($user)) { + throw ValidationException::withMessages(['access_token' => __('accounts.vk.invalid_token')]); + } + + return $user; + } + + /** + * Connect the community a community access token belongs to. Called with + * such a token and no group_id, groups.getById returns that community. + */ + private function storeCommunityAccount(Request $request, Workspace $workspace): InertiaResponse + { + $groups = $this->callVk($request->access_token, 'groups.getById', [ + 'fields' => 'screen_name,photo_200', + ]); + + // v5.199 отдаёт response.groups[], более старые версии — response[]. + $group = data_get($groups, 'groups.0') ?? data_get($groups, '0'); + + if (! is_array($group)) { + throw ValidationException::withMessages(['access_token' => __('accounts.vk.invalid_token')]); + } + + $ownerId = -(int) data_get($group, 'id'); + $photo = data_get($group, 'photo_200'); + + $workspace->socialAccounts()->updateOrCreate( + [ + 'platform' => $this->platform->value, + 'platform_user_id' => (string) $ownerId, + ], + [ + 'username' => data_get($group, 'screen_name'), + 'display_name' => (string) data_get($group, 'name'), + 'avatar_url' => $photo ? uploadFromUrl($photo) : null, + 'access_token' => $request->access_token, + 'refresh_token' => null, + // Community access tokens never expire; there is no refresh flow. + 'token_expires_at' => null, + 'status' => Status::Connected, + 'error_message' => null, + 'disconnected_at' => null, + 'meta' => [ + 'owner_id' => $ownerId, + 'is_group' => true, + 'community_token' => true, + ], + ], + ); + + return $this->popupCallback(true, __('accounts.popup_callback.connected'), $this->platform->value); + } + /** * Walls the token may publish to: the user's own profile plus communities * where the user is an administrator or editor. Keyed by owner_id so the diff --git a/app/Services/Social/ConnectionVerifier.php b/app/Services/Social/ConnectionVerifier.php index 595b43286..80b6840b2 100644 --- a/app/Services/Social/ConnectionVerifier.php +++ b/app/Services/Social/ConnectionVerifier.php @@ -11,10 +11,10 @@ use App\Exceptions\Social\GoogleBusinessPublishException; use App\Exceptions\Social\LinkedInPublishException; use App\Exceptions\Social\MastodonPublishException; -use App\Exceptions\Social\VkPublishException; use App\Exceptions\Social\PinterestPublishException; use App\Exceptions\Social\TelegramPublishException; use App\Exceptions\Social\TikTokPublishException; +use App\Exceptions\Social\VkPublishException; use App\Exceptions\Social\XPublishException; use App\Exceptions\Social\YouTubePublishException; use App\Exceptions\TokenExpiredException; @@ -23,6 +23,7 @@ use App\Services\Social\Meta\GraphError; use App\Services\Social\Telegram\TelegramApi; use App\Support\GoogleBusinessResourceName; +use App\Services\Social\Vk\VkApi; use Illuminate\Http\Client\PendingRequest; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Http; @@ -806,9 +807,13 @@ private function verifyGoogleBusiness(SocialAccount $account): bool private function verifyVk(SocialAccount $account): bool { + // users.get is unavailable with a community access token (error 27); + // groups.getById without a group_id returns that token's own community. + $method = data_get($account->meta, 'community_token') ? 'groups.getById' : 'users.get'; + $response = Http::asForm()->post( - \App\Services\Social\Vk\VkApi::endpoint('users.get'), - \App\Services\Social\Vk\VkApi::baseParams($account->access_token), + VkApi::endpoint($method), + VkApi::baseParams($account->access_token), ); if (VkPublishException::isConfirmedDeadToken($response)) { diff --git a/lang/ar/accounts.php b/lang/ar/accounts.php index 6a513a25a..d8e1a2503 100644 --- a/lang/ar/accounts.php +++ b/lang/ar/accounts.php @@ -69,7 +69,7 @@ 'description' => 'انشر في مجتمع أو على حائطك الشخصي', 'access_token' => 'رمز الوصول', 'access_token_placeholder' => 'vk1.a.…', - 'access_token_hint' => 'الصق رمز وصول مستخدم من VK بصلاحيات wall, photos, groups, video, offline. لم يعد VK يمنح صلاحية wall لتطبيقات OAuth الجديدة، فاستخدم رمزًا من تطبيق standalone خاص بك (أو خدمة مثل vkhost).', + 'access_token_hint' => 'موصى به: مفتاح وصول المجتمع (المجتمع ← الإدارة ← استخدام API ← مفاتيح الوصول؛ امنح الصور والحائط وإدارة المجتمع) — يعمل النشر مع أي نوع تطبيق. لا يسمح VK برفع الفيديو بمفاتيح المجتمع. يعمل أيضًا مفتاح المستخدم بصلاحيات wall, photos, groups, video, offline، لكن VK يسمح بالنشر على الحائط لتطبيقات standalone فقط.', 'pick_target' => 'اختر وجهة النشر', 'target_group' => 'مجتمع', 'target_profile' => 'ملف شخصي', diff --git a/lang/de/accounts.php b/lang/de/accounts.php index 47d0aeade..32d489f9c 100644 --- a/lang/de/accounts.php +++ b/lang/de/accounts.php @@ -71,7 +71,7 @@ 'description' => 'In einer Community oder auf deiner Pinnwand veröffentlichen', 'access_token' => 'Zugriffstoken', 'access_token_placeholder' => 'vk1.a.…', - 'access_token_hint' => 'Füge ein VK-Benutzer-Zugriffstoken mit den Berechtigungen wall, photos, groups, video, offline ein. Neuen OAuth-Apps gewährt VK die wall-Berechtigung nicht mehr; verwende daher ein Token einer eigenen Standalone-App (oder einen Dienst wie vkhost).', + 'access_token_hint' => 'Empfohlen: ein Community-Zugriffstoken (Community → Verwalten → API-Nutzung → Zugriffstokens; Fotos, Wall und Community-Verwaltung erlauben) — das Veröffentlichen funktioniert mit jedem App-Typ. Video-Upload erlaubt VK mit Community-Tokens nicht. Ein Benutzer-Zugriffstoken mit den Berechtigungen wall, photos, groups, video, offline funktioniert ebenfalls, aber Wall-Posts erlaubt VK nur Standalone-Apps.', 'pick_target' => 'Wo veröffentlichen?', 'target_group' => 'Community', 'target_profile' => 'Persönliches Profil', diff --git a/lang/el/accounts.php b/lang/el/accounts.php index 2ccbddc5c..d5001d3f2 100644 --- a/lang/el/accounts.php +++ b/lang/el/accounts.php @@ -69,7 +69,7 @@ 'description' => 'Δημοσιεύστε σε κοινότητα ή στον τοίχο σας', 'access_token' => 'Διακριτικό πρόσβασης', 'access_token_placeholder' => 'vk1.a.…', - 'access_token_hint' => 'Επικολλήστε ένα διακριτικό πρόσβασης χρήστη VK με τα δικαιώματα wall, photos, groups, video, offline. Το VK δεν παραχωρεί πλέον το δικαίωμα wall σε νέες εφαρμογές OAuth· χρησιμοποιήστε διακριτικό από δική σας standalone εφαρμογή (ή υπηρεσία όπως το vkhost).', + 'access_token_hint' => 'Συνιστάται: ένα διακριτικό πρόσβασης κοινότητας (κοινότητα → Διαχείριση → Χρήση API → Διακριτικά πρόσβασης, με δικαιώματα φωτογραφιών, τοίχου και διαχείρισης κοινότητας) — η δημοσίευση λειτουργεί με κάθε τύπο εφαρμογής. Το VK δεν επιτρέπει μεταφόρτωση βίντεο με διακριτικά κοινότητας. Λειτουργεί και διακριτικό χρήστη με δικαιώματα wall, photos, groups, video, offline, αλλά το VK επιτρέπει δημοσίευση στον τοίχο μόνο σε standalone εφαρμογές.', 'pick_target' => 'Πού να δημοσιευτεί', 'target_group' => 'Κοινότητα', 'target_profile' => 'Προσωπικό προφίλ', diff --git a/lang/en/accounts.php b/lang/en/accounts.php index c1f094fc1..4548f8f38 100644 --- a/lang/en/accounts.php +++ b/lang/en/accounts.php @@ -69,7 +69,7 @@ 'description' => 'Publish to a community or your profile wall', 'access_token' => 'Access token', 'access_token_placeholder' => 'vk1.a.…', - 'access_token_hint' => 'Paste a VK user access token with the wall, photos, groups, video, offline scopes. VK no longer grants the wall scope to new OAuth apps, so use a token from a standalone app you own (or a service like vkhost).', + 'access_token_hint' => 'Recommended: a community access token (community → Manage → API usage → Access tokens; grant photos, wall and community management) — publishing works with any app type. VK does not allow video upload with community tokens. A user access token with the wall, photos, groups, video, offline scopes also works, but VK only lets standalone apps post to walls.', 'pick_target' => 'Choose where to publish', 'target_group' => 'Community', 'target_profile' => 'Personal profile', diff --git a/lang/es/accounts.php b/lang/es/accounts.php index 7571de47b..9e9f55550 100644 --- a/lang/es/accounts.php +++ b/lang/es/accounts.php @@ -69,7 +69,7 @@ 'description' => 'Publica en una comunidad o en tu propio muro', 'access_token' => 'Token de acceso', 'access_token_placeholder' => 'vk1.a.…', - 'access_token_hint' => 'Pega un token de acceso de usuario de VK con los permisos wall, photos, groups, video, offline. VK ya no concede el permiso wall a nuevas apps OAuth; usa un token de una app standalone propia (o un servicio como vkhost).', + 'access_token_hint' => 'Recomendado: un token de acceso de comunidad (comunidad → Administrar → Uso de la API → Tokens de acceso; concede fotos, muro y gestión de la comunidad): la publicación funciona con cualquier tipo de aplicación. VK no permite subir vídeos con tokens de comunidad. También sirve un token de usuario con los permisos wall, photos, groups, video, offline, pero VK solo permite publicar en el muro a las aplicaciones standalone.', 'pick_target' => 'Dónde publicar', 'target_group' => 'Comunidad', 'target_profile' => 'Perfil personal', diff --git a/lang/fr/accounts.php b/lang/fr/accounts.php index cb118e9a7..c33c25f85 100644 --- a/lang/fr/accounts.php +++ b/lang/fr/accounts.php @@ -69,7 +69,7 @@ 'description' => 'Publier dans une communauté ou sur votre mur', 'access_token' => 'Jeton d\'accès', 'access_token_placeholder' => 'vk1.a.…', - 'access_token_hint' => 'Collez un jeton d\'accès utilisateur VK avec les autorisations wall, photos, groups, video, offline. VK n\'accorde plus l\'autorisation wall aux nouvelles applications OAuth ; utilisez un jeton d\'une application standalone à vous (ou un service comme vkhost).', + 'access_token_hint' => 'Recommandé : un token d\'accès de communauté (communauté → Gérer → Utilisation de l\'API → Tokens d\'accès ; accordez photos, mur et gestion de la communauté) — la publication fonctionne avec tout type d\'application. VK n\'autorise pas l\'envoi de vidéos avec un token de communauté. Un token utilisateur avec les permissions wall, photos, groups, video, offline fonctionne aussi, mais VK ne laisse publier sur le mur qu\'aux applications standalone.', 'pick_target' => 'Où publier', 'target_group' => 'Communauté', 'target_profile' => 'Profil personnel', diff --git a/lang/it/accounts.php b/lang/it/accounts.php index c2fcf4f78..1a39075cd 100644 --- a/lang/it/accounts.php +++ b/lang/it/accounts.php @@ -69,7 +69,7 @@ 'description' => 'Pubblica in una community o sulla tua bacheca', 'access_token' => 'Token di accesso', 'access_token_placeholder' => 'vk1.a.…', - 'access_token_hint' => 'Incolla un token di accesso utente VK con i permessi wall, photos, groups, video, offline. VK non concede più il permesso wall alle nuove app OAuth; usa un token di una tua app standalone (o un servizio come vkhost).', + 'access_token_hint' => 'Consigliato: un token di accesso della community (community → Gestisci → Utilizzo API → Token di accesso; concedi foto, bacheca e gestione della community) — la pubblicazione funziona con qualsiasi tipo di app. VK non consente il caricamento di video con i token della community. Funziona anche un token utente con i permessi wall, photos, groups, video, offline, ma VK consente di pubblicare in bacheca solo alle app standalone.', 'pick_target' => 'Dove pubblicare', 'target_group' => 'Community', 'target_profile' => 'Profilo personale', diff --git a/lang/ja/accounts.php b/lang/ja/accounts.php index 3879ee5cf..841889ed0 100644 --- a/lang/ja/accounts.php +++ b/lang/ja/accounts.php @@ -69,7 +69,7 @@ 'description' => 'コミュニティまたは自分のウォールに投稿します', 'access_token' => 'アクセストークン', 'access_token_placeholder' => 'vk1.a.…', - 'access_token_hint' => 'wall, photos, groups, video, offline スコープ付きのVKユーザーアクセストークンを貼り付けてください。VKは新しいOAuthアプリにwallスコープを付与しなくなったため、自分のスタンドアロンアプリのトークン(またはvkhostのようなサービス)を使用してください。', + 'access_token_hint' => '推奨: コミュニティアクセストークン(コミュニティ → 管理 → API の利用 → アクセストークン。写真・ウォール・コミュニティ管理を許可)— アプリ種別を問わず投稿できます。コミュニティトークンでは VK は動画アップロードを許可していません。wall, photos, groups, video, offline スコープのユーザートークンも使えますが、ウォール投稿は standalone アプリのみ許可されます。', 'pick_target' => '投稿先を選択', 'target_group' => 'コミュニティ', 'target_profile' => '個人プロフィール', diff --git a/lang/ko/accounts.php b/lang/ko/accounts.php index 66894e982..11e7875c4 100644 --- a/lang/ko/accounts.php +++ b/lang/ko/accounts.php @@ -69,7 +69,7 @@ 'description' => '커뮤니티 또는 내 담벼락에 게시합니다', 'access_token' => '액세스 토큰', 'access_token_placeholder' => 'vk1.a.…', - 'access_token_hint' => 'wall, photos, groups, video, offline 범위가 있는 VK 사용자 액세스 토큰을 붙여넣으세요. VK는 새 OAuth 앱에 더 이상 wall 범위를 부여하지 않으므로, 본인 소유 standalone 앱의 토큰(또는 vkhost 같은 서비스)을 사용하세요.', + 'access_token_hint' => '권장: 커뮤니티 액세스 토큰(커뮤니티 → 관리 → API 사용 → 액세스 토큰, 사진·담벼락·커뮤니티 관리 권한 부여) — 앱 유형과 무관하게 게시할 수 있습니다. 커뮤니티 토큰으로는 VK가 동영상 업로드를 허용하지 않습니다. wall, photos, groups, video, offline 권한의 사용자 토큰도 동작하지만, 담벼락 게시는 standalone 앱에만 허용됩니다.', 'pick_target' => '게시 위치 선택', 'target_group' => '커뮤니티', 'target_profile' => '개인 프로필', diff --git a/lang/nl/accounts.php b/lang/nl/accounts.php index 7faa4a338..f39401b92 100644 --- a/lang/nl/accounts.php +++ b/lang/nl/accounts.php @@ -69,7 +69,7 @@ 'description' => 'Publiceer in een community of op je eigen prikbord', 'access_token' => 'Toegangstoken', 'access_token_placeholder' => 'vk1.a.…', - 'access_token_hint' => 'Plak een VK-gebruikerstoegangstoken met de rechten wall, photos, groups, video, offline. VK geeft nieuwe OAuth-apps het wall-recht niet meer; gebruik een token van een eigen standalone-app (of een dienst zoals vkhost).', + 'access_token_hint' => 'Aanbevolen: een community-toegangstoken (community → Beheren → API-gebruik → Toegangstokens; geef foto\'s, prikbord en communitybeheer) — publiceren werkt met elk app-type. Video-upload staat VK met community-tokens niet toe. Een gebruikerstoken met de rechten wall, photos, groups, video, offline werkt ook, maar VK laat alleen standalone-apps op het prikbord posten.', 'pick_target' => 'Waar publiceren', 'target_group' => 'Community', 'target_profile' => 'Persoonlijk profiel', diff --git a/lang/pl/accounts.php b/lang/pl/accounts.php index f4cbe4a6d..2696006b7 100644 --- a/lang/pl/accounts.php +++ b/lang/pl/accounts.php @@ -69,7 +69,7 @@ 'description' => 'Publikuj w społeczności lub na własnej tablicy', 'access_token' => 'Token dostępu', 'access_token_placeholder' => 'vk1.a.…', - 'access_token_hint' => 'Wklej token dostępu użytkownika VK z uprawnieniami wall, photos, groups, video, offline. VK nie przyznaje już uprawnienia wall nowym aplikacjom OAuth; użyj tokenu własnej aplikacji standalone (lub serwisu takiego jak vkhost).', + 'access_token_hint' => 'Zalecane: klucz dostępu społeczności (społeczność → Zarządzanie → Praca z API → Klucze dostępu; zaznacz zdjęcia, tablicę i zarządzanie społecznością) — publikacja działa z każdym typem aplikacji. VK nie pozwala przesyłać wideo kluczem społeczności. Zadziała też token użytkownika z uprawnieniami wall, photos, groups, video, offline, ale publikować na tablicy VK pozwala tylko aplikacjom standalone.', 'pick_target' => 'Gdzie publikować', 'target_group' => 'Społeczność', 'target_profile' => 'Profil osobisty', diff --git a/lang/pt-BR/accounts.php b/lang/pt-BR/accounts.php index 225aae0ef..5578b28dc 100644 --- a/lang/pt-BR/accounts.php +++ b/lang/pt-BR/accounts.php @@ -69,7 +69,7 @@ 'description' => 'Publique em uma comunidade ou no seu mural', 'access_token' => 'Token de acesso', 'access_token_placeholder' => 'vk1.a.…', - 'access_token_hint' => 'Cole um token de acesso de usuário do VK com os escopos wall, photos, groups, video, offline. O VK não concede mais o escopo wall a novos apps OAuth; use um token de um app standalone seu (ou um serviço como o vkhost).', + 'access_token_hint' => 'Recomendado: um token de acesso da comunidade (comunidade → Gerenciar → Uso da API → Tokens de acesso; conceda fotos, mural e gestão da comunidade) — a publicação funciona com qualquer tipo de app. O VK não permite envio de vídeo com tokens de comunidade. Um token de usuário com os escopos wall, photos, groups, video, offline também funciona, mas o VK só permite postar no mural para apps standalone.', 'pick_target' => 'Onde publicar', 'target_group' => 'Comunidade', 'target_profile' => 'Perfil pessoal', diff --git a/lang/ru/accounts.php b/lang/ru/accounts.php index ac2073007..136d8afb4 100644 --- a/lang/ru/accounts.php +++ b/lang/ru/accounts.php @@ -69,7 +69,7 @@ 'description' => 'Публикация в сообщество или на свою стену', 'access_token' => 'Ключ доступа', 'access_token_placeholder' => 'vk1.a.…', - 'access_token_hint' => 'Вставьте пользовательский ключ доступа VK с правами wall, photos, groups, video, offline. Новым OAuth-приложениям VK не выдаёт право wall, поэтому используйте токен своего standalone-приложения (или сервис вроде vkhost).', + 'access_token_hint' => 'Рекомендуется ключ доступа сообщества (сообщество → Управление → Работа с API → Ключи доступа; отметьте фотографии, стену и управление сообществом) — публикация работает с любым типом приложения. Загрузку видео VK по ключу сообщества не разрешает. Подойдёт и пользовательский ключ с правами wall, photos, groups, video, offline, но постить на стену VK разрешает только standalone-приложениям.', 'pick_target' => 'Куда публиковать', 'target_group' => 'Сообщество', 'target_profile' => 'Личная страница', diff --git a/lang/tr/accounts.php b/lang/tr/accounts.php index 4c455418e..0b612febe 100644 --- a/lang/tr/accounts.php +++ b/lang/tr/accounts.php @@ -71,7 +71,7 @@ 'description' => 'Bir topluluğa veya kendi duvarınıza gönderi yayınlayın', 'access_token' => 'Erişim belirteci', 'access_token_placeholder' => 'vk1.a.…', - 'access_token_hint' => 'wall, photos, groups, video, offline kapsamlarına sahip bir VK kullanıcı erişim belirteci yapıştırın. VK, yeni OAuth uygulamalarına artık wall kapsamı vermiyor; kendi standalone uygulamanızın belirtecini (veya vkhost gibi bir hizmeti) kullanın.', + 'access_token_hint' => 'Önerilen: topluluk erişim anahtarı (topluluk → Yönet → API kullanımı → Erişim anahtarları; fotoğraflar, duvar ve topluluk yönetimi izinlerini verin) — yayınlama her uygulama türüyle çalışır. VK, topluluk anahtarıyla video yüklemeye izin vermez. wall, photos, groups, video, offline izinli bir kullanıcı anahtarı da çalışır, ancak VK duvara gönderiyi yalnızca standalone uygulamalara açar.', 'pick_target' => 'Nerede yayınlansın', 'target_group' => 'Topluluk', 'target_profile' => 'Kişisel profil', diff --git a/lang/uk/accounts.php b/lang/uk/accounts.php index a55689687..58a92ae63 100644 --- a/lang/uk/accounts.php +++ b/lang/uk/accounts.php @@ -69,7 +69,7 @@ 'description' => 'Публікація у спільноту або на власну стіну', 'access_token' => 'Ключ доступу', 'access_token_placeholder' => 'vk1.a.…', - 'access_token_hint' => 'Вставте користувацький ключ доступу VK із правами wall, photos, groups, video, offline. Новим OAuth-застосункам VK не надає право wall, тому використовуйте токен власного standalone-застосунку (або сервіс на кшталт vkhost).', + 'access_token_hint' => 'Рекомендовано ключ доступу спільноти (спільнота → Керування → Робота з API → Ключі доступу; позначте фотографії, стіну та керування спільнотою) — публікація працює з будь-яким типом застосунку. Завантаження відео за ключем спільноти VK не дозволяє. Підійде і користувацький ключ із правами wall, photos, groups, video, offline, але постити на стіну VK дозволяє лише standalone-застосункам.', 'pick_target' => 'Куди публікувати', 'target_group' => 'Спільнота', 'target_profile' => 'Особиста сторінка', diff --git a/lang/zh/accounts.php b/lang/zh/accounts.php index dec9c3327..f4e89eead 100644 --- a/lang/zh/accounts.php +++ b/lang/zh/accounts.php @@ -69,7 +69,7 @@ 'description' => '发布到社群或您自己的动态墙', 'access_token' => '访问令牌', 'access_token_placeholder' => 'vk1.a.…', - 'access_token_hint' => '粘贴具有 wall, photos, groups, video, offline 权限的 VK 用户访问令牌。VK 不再向新的 OAuth 应用授予 wall 权限,请使用您自己的 standalone 应用令牌(或 vkhost 之类的服务)。', + 'access_token_hint' => '推荐使用社区访问令牌(社区 → 管理 → API 使用 → 访问令牌;授予照片、留言墙和社区管理权限)—— 任何应用类型都可发布。VK 不允许使用社区令牌上传视频。带有 wall, photos, groups, video, offline 权限的用户令牌也可以,但 VK 仅允许 standalone 应用发布到留言墙。', 'pick_target' => '选择发布位置', 'target_group' => '社群', 'target_profile' => '个人主页', diff --git a/tests/Feature/Services/Social/ConnectionVerifierTest.php b/tests/Feature/Services/Social/ConnectionVerifierTest.php index 56259b4c4..8f87670b8 100644 --- a/tests/Feature/Services/Social/ConnectionVerifierTest.php +++ b/tests/Feature/Services/Social/ConnectionVerifierTest.php @@ -1306,3 +1306,46 @@ expect($account->fresh()->refresh_token)->toBe('refresh-rotated'); }); + +test('verifies a vk community-token account via groups.getById', function () { + $api = rtrim((string) config('trypost.platforms.vk.api'), '/'); + + Http::fake([ + "{$api}/groups.getById*" => Http::response([ + 'response' => ['groups' => [['id' => 123456, 'name' => 'Test Community']]], + ], 200), + ]); + + $account = SocialAccount::factory()->vk()->create([ + 'meta' => [ + 'owner_id' => -123456, + 'is_group' => true, + 'community_token' => true, + ], + ]); + + $verifier = new ConnectionVerifier; + + expect($verifier->verify($account))->toBeTrue(); + + Http::assertSentCount(1); + Http::assertSent(fn ($request) => str_contains($request->url(), '/groups.getById')); +}); + +test('verifies a vk user-token account via users.get', function () { + $api = rtrim((string) config('trypost.platforms.vk.api'), '/'); + + Http::fake([ + "{$api}/users.get*" => Http::response([ + 'response' => [['id' => 111, 'first_name' => 'Test', 'last_name' => 'User']], + ], 200), + ]); + + $account = SocialAccount::factory()->vk()->create(); + + $verifier = new ConnectionVerifier; + + expect($verifier->verify($account))->toBeTrue(); + + Http::assertSent(fn ($request) => str_contains($request->url(), '/users.get')); +}); diff --git a/tests/Feature/Social/VkControllerTest.php b/tests/Feature/Social/VkControllerTest.php index 711e27b1b..f4dda2760 100644 --- a/tests/Feature/Social/VkControllerTest.php +++ b/tests/Feature/Social/VkControllerTest.php @@ -131,3 +131,78 @@ function fakeVkIdentity(string $api): array $response->assertSessionHasErrors('access_token'); }); + +test('a community access token connects its community directly', function () { + Http::fake([ + "{$this->api}/users.get*" => Http::response([ + 'error' => [ + 'error_code' => 27, + 'error_msg' => 'Group authorization failed: method is unavailable with group auth.', + ], + ], 200), + "{$this->api}/groups.getById*" => Http::response([ + 'response' => [ + 'groups' => [ + [ + 'id' => 654321, + 'name' => 'NJ Soft', + 'screen_name' => 'njsoft', + 'photo_200' => null, + ], + ], + ], + ], 200), + ]); + + $response = $this->actingAs($this->user)->post(route('app.social.vk.store'), [ + 'access_token' => 'vk1.a.community-token', + ]); + + $response->assertOk(); + $response->assertInertia(fn (AssertableInertia $page) => $page + ->component('accounts/PopupCallback') + ->where('success', true)); + + $this->assertDatabaseHas('social_accounts', [ + 'workspace_id' => $this->workspace->id, + 'platform' => Platform::Vk->value, + 'platform_user_id' => '-654321', + 'username' => 'njsoft', + 'display_name' => 'NJ Soft', + 'status' => Status::Connected->value, + ]); + + $account = $this->workspace->socialAccounts()->where('platform', Platform::Vk->value)->first(); + + expect(data_get($account->meta, 'community_token'))->toBeTrue() + ->and(data_get($account->meta, 'owner_id'))->toBe(-654321) + ->and(data_get($account->meta, 'is_group'))->toBeTrue(); +}); + +test('a community token vk rejects surfaces the api error on the token field', function () { + Http::fake([ + "{$this->api}/users.get*" => Http::response([ + 'error' => [ + 'error_code' => 27, + 'error_msg' => 'Group authorization failed: method is unavailable with group auth.', + ], + ], 200), + "{$this->api}/groups.getById*" => Http::response([ + 'error' => [ + 'error_code' => 5, + 'error_msg' => 'Group authorization failed: invalid access_token.', + ], + ], 200), + ]); + + $response = $this->actingAs($this->user)->post(route('app.social.vk.store'), [ + 'access_token' => 'vk1.a.revoked-community-token', + ]); + + $response->assertSessionHasErrors('access_token'); + + $this->assertDatabaseMissing('social_accounts', [ + 'workspace_id' => $this->workspace->id, + 'platform' => Platform::Vk->value, + ]); +}); From 7675a427a097e84bb0a32564e76e65ed6540c9c9 Mon Sep 17 00:00:00 2001 From: Grigory Frolov <2168057+gynsus@users.noreply.github.com> Date: Sun, 30 Aug 2026 15:07:16 +0300 Subject: [PATCH 5/8] fix: VK community-token connect asks for the community address MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit VK has no API to resolve which community a token belongs to (groups.getById without group_ids returns nothing for community tokens), so the form asks for the community link/screen name and groups.getOnlineStatus — callable only with the community's own token — proves the key matches it. --- app/Http/Controllers/Auth/VkController.php | 50 ++++++++++++-- lang/ar/accounts.php | 5 ++ lang/de/accounts.php | 5 ++ lang/el/accounts.php | 5 ++ lang/en/accounts.php | 5 ++ lang/es/accounts.php | 5 ++ lang/fr/accounts.php | 5 ++ lang/it/accounts.php | 5 ++ lang/ja/accounts.php | 5 ++ lang/ko/accounts.php | 5 ++ lang/nl/accounts.php | 5 ++ lang/pl/accounts.php | 5 ++ lang/pt-BR/accounts.php | 5 ++ lang/ru/accounts.php | 5 ++ lang/tr/accounts.php | 5 ++ lang/uk/accounts.php | 5 ++ lang/zh/accounts.php | 5 ++ resources/js/pages/accounts/VkConnect.vue | 31 ++++++++- tests/Feature/Social/VkControllerTest.php | 77 +++++++++++++++++----- 19 files changed, 215 insertions(+), 23 deletions(-) diff --git a/app/Http/Controllers/Auth/VkController.php b/app/Http/Controllers/Auth/VkController.php index 71eb23977..a94888fb7 100644 --- a/app/Http/Controllers/Auth/VkController.php +++ b/app/Http/Controllers/Auth/VkController.php @@ -54,6 +54,7 @@ public function store(Request $request): InertiaResponse $request->validate([ 'access_token' => 'required|string|min:10', 'owner_id' => 'nullable|integer', + 'community' => 'nullable|string|max:255', ]); $workspace = $request->user()->currentWorkspace; @@ -64,9 +65,17 @@ public function store(Request $request): InertiaResponse $user = $this->fetchTokenUser($request->access_token); if ($user === null) { - // Community access token: it maps to exactly one community and - // wall.post with it is allowed regardless of the app type that - // issued it — connect that community directly, no second step. + // Community access token: wall.post with it is allowed + // regardless of the app type that issued it, but VK has no API + // to tell which community a token belongs to — the form asks + // for the community address and the token is checked against it. + if (! $request->filled('community')) { + return Inertia::render('accounts/VkConnect', [ + 'errors' => [], + 'communityToken' => true, + ]); + } + return $this->storeCommunityAccount($request, $workspace); } @@ -124,6 +133,24 @@ public function store(Request $request): InertiaResponse } } + /** + * A community as the user typed it — a full URL, a `club123` / `public123` + * address, a bare numeric id, or a screen name — normalized to what + * groups.getById accepts in `group_ids`. + */ + private function normalizeCommunity(string $input): string + { + $value = trim($input); + $value = (string) preg_replace('#^https?://[^/]+/#i', '', $value); + $value = trim($value, '/'); + + if (preg_match('/^(?:club|public|event)(\d+)$/i', $value, $matches)) { + return $matches[1]; + } + + return ltrim($value, '-'); + } + /** * The user behind a user access token, or null when the token is a * community access token (users.get answers with error 27 for those). @@ -165,12 +192,15 @@ private function fetchTokenUser(string $accessToken): ?array } /** - * Connect the community a community access token belongs to. Called with - * such a token and no group_id, groups.getById returns that community. + * Connect the community a community access token belongs to. VK has no + * API to resolve a community from its token, so the community comes from + * the form; groups.getOnlineStatus (callable only with the community's + * own token) then proves the token actually belongs to it. */ private function storeCommunityAccount(Request $request, Workspace $workspace): InertiaResponse { $groups = $this->callVk($request->access_token, 'groups.getById', [ + 'group_ids' => $this->normalizeCommunity((string) $request->community), 'fields' => 'screen_name,photo_200', ]); @@ -178,7 +208,15 @@ private function storeCommunityAccount(Request $request, Workspace $workspace): $group = data_get($groups, 'groups.0') ?? data_get($groups, '0'); if (! is_array($group)) { - throw ValidationException::withMessages(['access_token' => __('accounts.vk.invalid_token')]); + throw ValidationException::withMessages(['community' => __('accounts.vk.invalid_community')]); + } + + $mismatch = Http::asForm()->post(VkApi::endpoint('groups.getOnlineStatus'), [ + 'group_id' => (int) data_get($group, 'id'), + ] + VkApi::baseParams($request->access_token))->json('error') !== null; + + if ($mismatch) { + throw ValidationException::withMessages(['community' => __('accounts.vk.community_token_mismatch')]); } $ownerId = -(int) data_get($group, 'id'); diff --git a/lang/ar/accounts.php b/lang/ar/accounts.php index d8e1a2503..32ab83904 100644 --- a/lang/ar/accounts.php +++ b/lang/ar/accounts.php @@ -75,6 +75,11 @@ 'target_profile' => 'ملف شخصي', 'invalid_token' => 'رفض VK هذا الرمز.', 'invalid_target' => 'لا يمكن إدارة هذا الحائط بالرمز المقدّم.', + 'community' => 'المجتمع', + 'community_placeholder' => 'vk.com/yourclub', + 'community_hint' => 'لا يكشف VK عن المجتمع الذي ينتمي إليه المفتاح، لذا أدخل عنوانه أو اسمه المختصر — ثم يتم التحقق من انتماء المفتاح.', + 'invalid_community' => 'لم يتم العثور على المجتمع. أدخل عنوانًا مثل vk.com/yourclub.', + 'community_token_mismatch' => 'هذا المفتاح ينتمي إلى مجتمع آخر.', 'connection_error' => 'خطأ في الاتصال بـ VK. حاول مرة أخرى.', 'submit' => 'ربط VK', 'submitting' => 'جارٍ الاتصال...', diff --git a/lang/de/accounts.php b/lang/de/accounts.php index 32d489f9c..5b43569df 100644 --- a/lang/de/accounts.php +++ b/lang/de/accounts.php @@ -77,6 +77,11 @@ 'target_profile' => 'Persönliches Profil', 'invalid_token' => 'VK hat dieses Token abgelehnt.', 'invalid_target' => 'Diese Pinnwand ist mit dem angegebenen Token nicht verwaltbar.', + 'community' => 'Community', + 'community_placeholder' => 'vk.com/yourclub', + 'community_hint' => 'VK verrät nicht, zu welcher Community ein Schlüssel gehört. Gib daher ihre Adresse oder ihren Kurznamen ein — die Zugehörigkeit wird anschließend geprüft.', + 'invalid_community' => 'Community nicht gefunden. Gib die Adresse im Format vk.com/yourclub ein.', + 'community_token_mismatch' => 'Dieser Schlüssel gehört zu einer anderen Community.', 'connection_error' => 'Fehler beim Verbinden mit VK. Bitte erneut versuchen.', 'submit' => 'VK verbinden', 'submitting' => 'Verbinden...', diff --git a/lang/el/accounts.php b/lang/el/accounts.php index d5001d3f2..748a52f57 100644 --- a/lang/el/accounts.php +++ b/lang/el/accounts.php @@ -75,6 +75,11 @@ 'target_profile' => 'Προσωπικό προφίλ', 'invalid_token' => 'Το VK απέρριψε αυτό το διακριτικό.', 'invalid_target' => 'Αυτός ο τοίχος δεν μπορεί να διαχειριστεί με το παρεχόμενο διακριτικό.', + 'community' => 'Κοινότητα', + 'community_placeholder' => 'vk.com/yourclub', + 'community_hint' => 'Το VK δεν αποκαλύπτει σε ποια κοινότητα ανήκει ένα κλειδί, γι’ αυτό εισαγάγετε τη διεύθυνση ή το σύντομο όνομά της — στη συνέχεια ελέγχεται η αντιστοιχία του κλειδιού.', + 'invalid_community' => 'Η κοινότητα δεν βρέθηκε. Εισαγάγετε διεύθυνση της μορφής vk.com/yourclub.', + 'community_token_mismatch' => 'Αυτό το κλειδί ανήκει σε άλλη κοινότητα.', 'connection_error' => 'Σφάλμα σύνδεσης με το VK. Δοκιμάστε ξανά.', 'submit' => 'Σύνδεση VK', 'submitting' => 'Σύνδεση...', diff --git a/lang/en/accounts.php b/lang/en/accounts.php index 4548f8f38..1dadf26c5 100644 --- a/lang/en/accounts.php +++ b/lang/en/accounts.php @@ -75,6 +75,11 @@ 'target_profile' => 'Personal profile', 'invalid_token' => 'VK rejected this token.', 'invalid_target' => 'This wall cannot be managed with the provided token.', + 'community' => 'Community', + 'community_placeholder' => 'vk.com/yourclub', + 'community_hint' => 'VK cannot tell which community a key belongs to, so enter its address or screen name — the key is then checked against it.', + 'invalid_community' => 'Community not found. Enter its address like vk.com/yourclub.', + 'community_token_mismatch' => 'This key belongs to a different community.', 'connection_error' => 'Error connecting to VK. Please try again.', 'submit' => 'Connect VK', 'submitting' => 'Connecting...', diff --git a/lang/es/accounts.php b/lang/es/accounts.php index 9e9f55550..8b38a632f 100644 --- a/lang/es/accounts.php +++ b/lang/es/accounts.php @@ -75,6 +75,11 @@ 'target_profile' => 'Perfil personal', 'invalid_token' => 'VK rechazó este token.', 'invalid_target' => 'Este muro no se puede gestionar con el token indicado.', + 'community' => 'Comunidad', + 'community_placeholder' => 'vk.com/yourclub', + 'community_hint' => 'VK no indica a qué comunidad pertenece una clave, así que introduce su dirección o nombre corto: después se comprueba que la clave le pertenece.', + 'invalid_community' => 'Comunidad no encontrada. Introduce una dirección como vk.com/yourclub.', + 'community_token_mismatch' => 'Esta clave pertenece a otra comunidad.', 'connection_error' => 'Error al conectar con VK. Inténtalo de nuevo.', 'submit' => 'Conectar VK', 'submitting' => 'Conectando...', diff --git a/lang/fr/accounts.php b/lang/fr/accounts.php index c33c25f85..94c5f36c3 100644 --- a/lang/fr/accounts.php +++ b/lang/fr/accounts.php @@ -75,6 +75,11 @@ 'target_profile' => 'Profil personnel', 'invalid_token' => 'VK a rejeté ce jeton.', 'invalid_target' => 'Ce mur ne peut pas être géré avec le jeton fourni.', + 'community' => 'Communauté', + 'community_placeholder' => 'vk.com/yourclub', + 'community_hint' => 'VK n’indique pas à quelle communauté appartient une clé : saisissez son adresse ou son nom court — l’appartenance de la clé est ensuite vérifiée.', + 'invalid_community' => 'Communauté introuvable. Saisissez une adresse comme vk.com/yourclub.', + 'community_token_mismatch' => 'Cette clé appartient à une autre communauté.', 'connection_error' => 'Erreur de connexion à VK. Veuillez réessayer.', 'submit' => 'Connecter VK', 'submitting' => 'Connexion...', diff --git a/lang/it/accounts.php b/lang/it/accounts.php index 1a39075cd..bce99dc99 100644 --- a/lang/it/accounts.php +++ b/lang/it/accounts.php @@ -75,6 +75,11 @@ 'target_profile' => 'Profilo personale', 'invalid_token' => 'VK ha rifiutato questo token.', 'invalid_target' => 'Questa bacheca non è gestibile con il token fornito.', + 'community' => 'Community', + 'community_placeholder' => 'vk.com/yourclub', + 'community_hint' => 'VK non indica a quale community appartiene una chiave: inserisci il suo indirizzo o nome breve — l’appartenenza della chiave viene poi verificata.', + 'invalid_community' => 'Community non trovata. Inserisci un indirizzo come vk.com/yourclub.', + 'community_token_mismatch' => 'Questa chiave appartiene a un’altra community.', 'connection_error' => 'Errore di connessione a VK. Riprova.', 'submit' => 'Collega VK', 'submitting' => 'Connessione...', diff --git a/lang/ja/accounts.php b/lang/ja/accounts.php index 841889ed0..a619734dd 100644 --- a/lang/ja/accounts.php +++ b/lang/ja/accounts.php @@ -75,6 +75,11 @@ 'target_profile' => '個人プロフィール', 'invalid_token' => 'VKがこのトークンを拒否しました。', 'invalid_target' => 'このウォールは指定されたトークンでは管理できません。', + 'community' => 'コミュニティ', + 'community_placeholder' => 'vk.com/yourclub', + 'community_hint' => 'VK はキーがどのコミュニティのものか教えてくれないため、コミュニティのアドレスまたはスクリーンネームを入力してください。その後、キーの所属を確認します。', + 'invalid_community' => 'コミュニティが見つかりません。vk.com/yourclub の形式で入力してください。', + 'community_token_mismatch' => 'このキーは別のコミュニティのものです。', 'connection_error' => 'VKへの接続エラーです。もう一度お試しください。', 'submit' => 'VKを連携', 'submitting' => '接続中...', diff --git a/lang/ko/accounts.php b/lang/ko/accounts.php index 11e7875c4..a3835b095 100644 --- a/lang/ko/accounts.php +++ b/lang/ko/accounts.php @@ -75,6 +75,11 @@ 'target_profile' => '개인 프로필', 'invalid_token' => 'VK가 이 토큰을 거부했습니다.', 'invalid_target' => '제공된 토큰으로는 이 담벼락을 관리할 수 없습니다.', + 'community' => '커뮤니티', + 'community_placeholder' => 'vk.com/yourclub', + 'community_hint' => 'VK는 키가 어느 커뮤니티의 것인지 알려주지 않으므로 커뮤니티 주소나 짧은 이름을 입력하세요. 이후 키의 소속이 확인됩니다.', + 'invalid_community' => '커뮤니티를 찾을 수 없습니다. vk.com/yourclub 형식의 주소를 입력하세요.', + 'community_token_mismatch' => '이 키는 다른 커뮤니티의 것입니다.', 'connection_error' => 'VK 연결 중 오류가 발생했습니다. 다시 시도해 주세요.', 'submit' => 'VK 연결', 'submitting' => '연결 중...', diff --git a/lang/nl/accounts.php b/lang/nl/accounts.php index f39401b92..2c8411684 100644 --- a/lang/nl/accounts.php +++ b/lang/nl/accounts.php @@ -75,6 +75,11 @@ 'target_profile' => 'Persoonlijk profiel', 'invalid_token' => 'VK heeft dit token geweigerd.', 'invalid_target' => 'Dit prikbord is niet beheerbaar met het opgegeven token.', + 'community' => 'Community', + 'community_placeholder' => 'vk.com/yourclub', + 'community_hint' => 'VK vertelt niet bij welke community een sleutel hoort. Voer daarom het adres of de korte naam in — daarna wordt gecontroleerd of de sleutel erbij hoort.', + 'invalid_community' => 'Community niet gevonden. Voer een adres in zoals vk.com/yourclub.', + 'community_token_mismatch' => 'Deze sleutel hoort bij een andere community.', 'connection_error' => 'Fout bij verbinden met VK. Probeer het opnieuw.', 'submit' => 'VK verbinden', 'submitting' => 'Verbinden...', diff --git a/lang/pl/accounts.php b/lang/pl/accounts.php index 2696006b7..c41f2d1a8 100644 --- a/lang/pl/accounts.php +++ b/lang/pl/accounts.php @@ -75,6 +75,11 @@ 'target_profile' => 'Profil osobisty', 'invalid_token' => 'VK odrzucił ten token.', 'invalid_target' => 'Tą tablicą nie można zarządzać podanym tokenem.', + 'community' => 'Społeczność', + 'community_placeholder' => 'vk.com/yourclub', + 'community_hint' => 'VK nie ujawnia, do której społeczności należy klucz, więc podaj jej adres lub krótką nazwę — przynależność klucza zostanie następnie sprawdzona.', + 'invalid_community' => 'Nie znaleziono społeczności. Podaj adres w formie vk.com/yourclub.', + 'community_token_mismatch' => 'Ten klucz należy do innej społeczności.', 'connection_error' => 'Błąd połączenia z VK. Spróbuj ponownie.', 'submit' => 'Połącz VK', 'submitting' => 'Łączenie...', diff --git a/lang/pt-BR/accounts.php b/lang/pt-BR/accounts.php index 5578b28dc..b221112d4 100644 --- a/lang/pt-BR/accounts.php +++ b/lang/pt-BR/accounts.php @@ -75,6 +75,11 @@ 'target_profile' => 'Perfil pessoal', 'invalid_token' => 'O VK rejeitou este token.', 'invalid_target' => 'Este mural não pode ser gerenciado com o token informado.', + 'community' => 'Comunidade', + 'community_placeholder' => 'vk.com/yourclub', + 'community_hint' => 'O VK não informa a qual comunidade uma chave pertence; informe o endereço ou nome curto dela — a chave é então verificada.', + 'invalid_community' => 'Comunidade não encontrada. Informe um endereço como vk.com/yourclub.', + 'community_token_mismatch' => 'Esta chave pertence a outra comunidade.', 'connection_error' => 'Erro ao conectar ao VK. Tente novamente.', 'submit' => 'Conectar VK', 'submitting' => 'Conectando...', diff --git a/lang/ru/accounts.php b/lang/ru/accounts.php index 136d8afb4..3e17b83d1 100644 --- a/lang/ru/accounts.php +++ b/lang/ru/accounts.php @@ -75,6 +75,11 @@ 'target_profile' => 'Личная страница', 'invalid_token' => 'VK отклонил этот токен.', 'invalid_target' => 'Эта стена недоступна для указанного токена.', + 'community' => 'Сообщество', + 'community_placeholder' => 'vk.com/yourclub', + 'community_hint' => 'VK не сообщает, какому сообществу принадлежит ключ, поэтому укажите его адрес или короткое имя — принадлежность ключа будет проверена.', + 'invalid_community' => 'Сообщество не найдено. Укажите адрес вида vk.com/yourclub.', + 'community_token_mismatch' => 'Этот ключ принадлежит другому сообществу.', 'connection_error' => 'Ошибка подключения к VK. Попробуйте ещё раз.', 'submit' => 'Подключить VK', 'submitting' => 'Подключение...', diff --git a/lang/tr/accounts.php b/lang/tr/accounts.php index 0b612febe..ec5725ac5 100644 --- a/lang/tr/accounts.php +++ b/lang/tr/accounts.php @@ -77,6 +77,11 @@ 'target_profile' => 'Kişisel profil', 'invalid_token' => 'VK bu belirteci reddetti.', 'invalid_target' => 'Bu duvar, verilen belirteçle yönetilemiyor.', + 'community' => 'Topluluk', + 'community_placeholder' => 'vk.com/yourclub', + 'community_hint' => 'VK bir anahtarın hangi topluluğa ait olduğunu bildirmez; topluluğun adresini veya kısa adını girin — anahtarın aidiyeti ardından doğrulanır.', + 'invalid_community' => 'Topluluk bulunamadı. vk.com/yourclub biçiminde bir adres girin.', + 'community_token_mismatch' => 'Bu anahtar başka bir topluluğa ait.', 'connection_error' => 'VK\'ya bağlanırken hata oluştu. Lütfen tekrar deneyin.', 'submit' => 'VK\'yı bağla', 'submitting' => 'Bağlanıyor...', diff --git a/lang/uk/accounts.php b/lang/uk/accounts.php index 58a92ae63..065ae4684 100644 --- a/lang/uk/accounts.php +++ b/lang/uk/accounts.php @@ -75,6 +75,11 @@ 'target_profile' => 'Особиста сторінка', 'invalid_token' => 'VK відхилив цей токен.', 'invalid_target' => 'Ця стіна недоступна для вказаного токена.', + 'community' => 'Спільнота', + 'community_placeholder' => 'vk.com/yourclub', + 'community_hint' => 'VK не повідомляє, якій спільноті належить ключ, тому вкажіть її адресу або коротке ім’я — приналежність ключа буде перевірено.', + 'invalid_community' => 'Спільноту не знайдено. Вкажіть адресу на кшталт vk.com/yourclub.', + 'community_token_mismatch' => 'Цей ключ належить іншій спільноті.', 'connection_error' => 'Помилка підключення до VK. Спробуйте ще раз.', 'submit' => 'Підключити VK', 'submitting' => 'Підключення...', diff --git a/lang/zh/accounts.php b/lang/zh/accounts.php index f4e89eead..ad1217542 100644 --- a/lang/zh/accounts.php +++ b/lang/zh/accounts.php @@ -75,6 +75,11 @@ 'target_profile' => '个人主页', 'invalid_token' => 'VK 拒绝了该令牌。', 'invalid_target' => '所提供的令牌无法管理此动态墙。', + 'community' => '社区', + 'community_placeholder' => 'vk.com/yourclub', + 'community_hint' => 'VK 不会告知令牌属于哪个社区,请输入社区地址或短名称——随后会校验令牌归属。', + 'invalid_community' => '未找到社区。请输入形如 vk.com/yourclub 的地址。', + 'community_token_mismatch' => '此令牌属于另一个社区。', 'connection_error' => '连接 VK 时出错,请重试。', 'submit' => '连接 VK', 'submitting' => '连接中...', diff --git a/resources/js/pages/accounts/VkConnect.vue b/resources/js/pages/accounts/VkConnect.vue index 018e3f0e5..f8b20adab 100644 --- a/resources/js/pages/accounts/VkConnect.vue +++ b/resources/js/pages/accounts/VkConnect.vue @@ -22,11 +22,13 @@ interface VkTarget { const props = defineProps<{ targets?: VkTarget[]; + communityToken?: boolean; }>(); -const form = useForm<{ access_token: string; owner_id: number | null }>({ +const form = useForm<{ access_token: string; owner_id: number | null; community: string }>({ access_token: '', owner_id: null, + community: '', }); const hasTargets = computed(() => (props.targets ?? []).length > 0); @@ -85,6 +87,33 @@ const pickTarget = (target: VkTarget) => {

+ +
+
+ + +

+ {{ form.errors.community }} +

+

+ {{ form.errors.access_token }} +

+
+ + + + + {{ $t('accounts.vk.community_hint') }} + + + + +
+
diff --git a/tests/Feature/Social/VkControllerTest.php b/tests/Feature/Social/VkControllerTest.php index f4dda2760..a1a432174 100644 --- a/tests/Feature/Social/VkControllerTest.php +++ b/tests/Feature/Social/VkControllerTest.php @@ -132,15 +132,16 @@ function fakeVkIdentity(string $api): array $response->assertSessionHasErrors('access_token'); }); -test('a community access token connects its community directly', function () { - Http::fake([ - "{$this->api}/users.get*" => Http::response([ +function fakeVkCommunityToken(string $api): array +{ + return [ + "{$api}/users.get*" => Http::response([ 'error' => [ 'error_code' => 27, 'error_msg' => 'Group authorization failed: method is unavailable with group auth.', ], ], 200), - "{$this->api}/groups.getById*" => Http::response([ + "{$api}/groups.getById*" => Http::response([ 'response' => [ 'groups' => [ [ @@ -152,10 +153,36 @@ function fakeVkIdentity(string $api): array ], ], ], 200), + "{$api}/groups.getOnlineStatus*" => Http::response([ + 'response' => ['status' => 'none'], + ], 200), + ]; +} + +test('a community access token asks for the community address first', function () { + Http::fake(fakeVkCommunityToken($this->api)); + + $response = $this->actingAs($this->user)->post(route('app.social.vk.store'), [ + 'access_token' => 'vk1.a.community-token', ]); + $response->assertOk(); + $response->assertInertia(fn (AssertableInertia $page) => $page + ->component('accounts/VkConnect') + ->where('communityToken', true)); + + $this->assertDatabaseMissing('social_accounts', [ + 'workspace_id' => $this->workspace->id, + 'platform' => Platform::Vk->value, + ]); +}); + +test('a community access token connects its community', function () { + Http::fake(fakeVkCommunityToken($this->api)); + $response = $this->actingAs($this->user)->post(route('app.social.vk.store'), [ 'access_token' => 'vk1.a.community-token', + 'community' => 'https://vk.com/njsoft', ]); $response->assertOk(); @@ -163,6 +190,9 @@ function fakeVkIdentity(string $api): array ->component('accounts/PopupCallback') ->where('success', true)); + Http::assertSent(fn ($request) => str_contains($request->url(), '/groups.getById') + && $request['group_ids'] === 'njsoft'); + $this->assertDatabaseHas('social_accounts', [ 'workspace_id' => $this->workspace->id, 'platform' => Platform::Vk->value, @@ -179,27 +209,42 @@ function fakeVkIdentity(string $api): array ->and(data_get($account->meta, 'is_group'))->toBeTrue(); }); -test('a community token vk rejects surfaces the api error on the token field', function () { - Http::fake([ - "{$this->api}/users.get*" => Http::response([ +test('a community token of a different community is rejected', function () { + Http::fake(array_merge(fakeVkCommunityToken($this->api), [ + "{$this->api}/groups.getOnlineStatus*" => Http::response([ 'error' => [ - 'error_code' => 27, - 'error_msg' => 'Group authorization failed: method is unavailable with group auth.', + 'error_code' => 15, + 'error_msg' => 'Access denied: group access token is not suitable for this group.', ], ], 200), + ])); + + $response = $this->actingAs($this->user)->post(route('app.social.vk.store'), [ + 'access_token' => 'vk1.a.other-community-token', + 'community' => 'njsoft', + ]); + + $response->assertSessionHasErrors('community'); + + $this->assertDatabaseMissing('social_accounts', [ + 'workspace_id' => $this->workspace->id, + 'platform' => Platform::Vk->value, + ]); +}); + +test('an unknown community address surfaces a validation error', function () { + Http::fake(array_merge(fakeVkCommunityToken($this->api), [ "{$this->api}/groups.getById*" => Http::response([ - 'error' => [ - 'error_code' => 5, - 'error_msg' => 'Group authorization failed: invalid access_token.', - ], + 'response' => ['groups' => []], ], 200), - ]); + ])); $response = $this->actingAs($this->user)->post(route('app.social.vk.store'), [ - 'access_token' => 'vk1.a.revoked-community-token', + 'access_token' => 'vk1.a.community-token', + 'community' => 'no-such-club', ]); - $response->assertSessionHasErrors('access_token'); + $response->assertSessionHasErrors('community'); $this->assertDatabaseMissing('social_accounts', [ 'workspace_id' => $this->workspace->id, From cb70d26e16946a9c0343192ef5d196a5741a840b Mon Sep 17 00:00:00 2001 From: Grigory Frolov <2168057+gynsus@users.noreply.github.com> Date: Sun, 30 Aug 2026 15:17:34 +0300 Subject: [PATCH 6/8] fix: VK community tokens answer users.get with an empty list, not error 27 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit users.get is callable with a community access token — without user_ids it returns an empty response instead of a group-auth error, so a successful-but-empty users.get is the community-token signal. --- app/Http/Controllers/Auth/VkController.php | 6 +++++- tests/Feature/Social/VkControllerTest.php | 9 +++------ 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/app/Http/Controllers/Auth/VkController.php b/app/Http/Controllers/Auth/VkController.php index a94888fb7..8bedfff94 100644 --- a/app/Http/Controllers/Auth/VkController.php +++ b/app/Http/Controllers/Auth/VkController.php @@ -185,7 +185,11 @@ private function fetchTokenUser(string $accessToken): ?array $user = $response->json('response.0'); if (! is_array($user)) { - throw ValidationException::withMessages(['access_token' => __('accounts.vk.invalid_token')]); + // users.get is callable with a community access token too — it + // just returns an empty list without user_ids. A successful but + // empty response therefore means a community token, not a broken + // one (a dead token errors out above with VK's own message). + return null; } return $user; diff --git a/tests/Feature/Social/VkControllerTest.php b/tests/Feature/Social/VkControllerTest.php index a1a432174..f1fd11989 100644 --- a/tests/Feature/Social/VkControllerTest.php +++ b/tests/Feature/Social/VkControllerTest.php @@ -135,12 +135,9 @@ function fakeVkIdentity(string $api): array function fakeVkCommunityToken(string $api): array { return [ - "{$api}/users.get*" => Http::response([ - 'error' => [ - 'error_code' => 27, - 'error_msg' => 'Group authorization failed: method is unavailable with group auth.', - ], - ], 200), + // users.get принимает ключ сообщества, но без user_ids отвечает + // пустым списком — так и распознаётся ключ сообщества. + "{$api}/users.get*" => Http::response(['response' => []], 200), "{$api}/groups.getById*" => Http::response([ 'response' => [ 'groups' => [ From e91fa59a55f6939d86560ffaf8428d56433915c0 Mon Sep 17 00:00:00 2001 From: Grigory Frolov <2168057+gynsus@users.noreply.github.com> Date: Sun, 30 Aug 2026 15:45:31 +0300 Subject: [PATCH 7/8] fix: prove VK community-token ownership via getCallbackConfirmationCode groups.getOnlineStatus fails with 'group messages is disabled' for communities that turned messages off; getCallbackConfirmationCode works with the community's own token regardless and still rejects foreign tokens. --- app/Enums/SocialAccount/Platform.php | 1 + app/Http/Controllers/Auth/VkController.php | 7 ++++--- tests/Feature/Social/VkControllerTest.php | 8 ++++---- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/app/Enums/SocialAccount/Platform.php b/app/Enums/SocialAccount/Platform.php index 85bf6f042..996c7500c 100644 --- a/app/Enums/SocialAccount/Platform.php +++ b/app/Enums/SocialAccount/Platform.php @@ -438,6 +438,7 @@ public function isEnabled(): bool self::Telegram => 'TELEGRAM_ENABLED', self::Discord => 'DISCORD_ENABLED', self::GoogleBusiness => 'GOOGLE_BUSINESS_ENABLED', + self::Vk => 'VK_ENABLED', }, true), ); } diff --git a/app/Http/Controllers/Auth/VkController.php b/app/Http/Controllers/Auth/VkController.php index 8bedfff94..57d920a93 100644 --- a/app/Http/Controllers/Auth/VkController.php +++ b/app/Http/Controllers/Auth/VkController.php @@ -198,8 +198,9 @@ private function fetchTokenUser(string $accessToken): ?array /** * Connect the community a community access token belongs to. VK has no * API to resolve a community from its token, so the community comes from - * the form; groups.getOnlineStatus (callable only with the community's - * own token) then proves the token actually belongs to it. + * the form; groups.getCallbackConfirmationCode (callable only with the + * community's own token, unlike groups.getOnlineStatus it does not need + * community messages to be enabled) then proves the token belongs to it. */ private function storeCommunityAccount(Request $request, Workspace $workspace): InertiaResponse { @@ -215,7 +216,7 @@ private function storeCommunityAccount(Request $request, Workspace $workspace): throw ValidationException::withMessages(['community' => __('accounts.vk.invalid_community')]); } - $mismatch = Http::asForm()->post(VkApi::endpoint('groups.getOnlineStatus'), [ + $mismatch = Http::asForm()->post(VkApi::endpoint('groups.getCallbackConfirmationCode'), [ 'group_id' => (int) data_get($group, 'id'), ] + VkApi::baseParams($request->access_token))->json('error') !== null; diff --git a/tests/Feature/Social/VkControllerTest.php b/tests/Feature/Social/VkControllerTest.php index f1fd11989..8cafc53db 100644 --- a/tests/Feature/Social/VkControllerTest.php +++ b/tests/Feature/Social/VkControllerTest.php @@ -150,8 +150,8 @@ function fakeVkCommunityToken(string $api): array ], ], ], 200), - "{$api}/groups.getOnlineStatus*" => Http::response([ - 'response' => ['status' => 'none'], + "{$api}/groups.getCallbackConfirmationCode*" => Http::response([ + 'response' => ['code' => '0f3f31b6'], ], 200), ]; } @@ -208,10 +208,10 @@ function fakeVkCommunityToken(string $api): array test('a community token of a different community is rejected', function () { Http::fake(array_merge(fakeVkCommunityToken($this->api), [ - "{$this->api}/groups.getOnlineStatus*" => Http::response([ + "{$this->api}/groups.getCallbackConfirmationCode*" => Http::response([ 'error' => [ 'error_code' => 15, - 'error_msg' => 'Access denied: group access token is not suitable for this group.', + 'error_msg' => 'Access denied: no access to this group', ], ], 200), ])); From 0372677f528dcf59d8aa5fa8bb9f3f6b1140fc3f Mon Sep 17 00:00:00 2001 From: Grigory Frolov <2168057+gynsus@users.noreply.github.com> Date: Tue, 22 Sep 2026 11:58:16 +0300 Subject: [PATCH 8/8] test: cover VK in the platform queue and enablement datasets --- tests/Unit/Enums/PlatformTest.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/Unit/Enums/PlatformTest.php b/tests/Unit/Enums/PlatformTest.php index 3a79942d8..947b033a6 100644 --- a/tests/Unit/Enums/PlatformTest.php +++ b/tests/Unit/Enums/PlatformTest.php @@ -124,6 +124,7 @@ Platform::Telegram, Platform::Discord, Platform::GoogleBusiness, + Platform::Vk, ]); test('each platform can be disabled via config', function (Platform $platform) { @@ -146,6 +147,7 @@ Platform::Telegram, Platform::Discord, Platform::GoogleBusiness, + Platform::Vk, ]); test('each platform maps to its publishing queue', function (Platform $platform, string $queue) { @@ -166,6 +168,7 @@ [Platform::Telegram, 'social-telegram'], [Platform::Discord, 'social-discord'], [Platform::GoogleBusiness, 'social-google_business'], + [Platform::Vk, 'social-vk'], ]); test('allQueues lists every platform publishing queue in enum order', function () { @@ -185,6 +188,7 @@ 'social-telegram', 'social-discord', 'social-google_business', + 'social-vk', ])->and(Platform::allQueues())->toHaveCount(count(Platform::cases())); }); @@ -234,6 +238,7 @@ Platform::Telegram, Platform::Discord, Platform::GoogleBusiness, + Platform::Vk, ]); test('disabling every platform yields no enabled queues', function () {