From afe8a9f202d32ad1a1a87848078dc929212da4ff Mon Sep 17 00:00:00 2001 From: Grigory Frolov <2168057+gynsus@users.noreply.github.com> Date: Sun, 11 Oct 2026 11:14:48 +0300 Subject: [PATCH 1/2] feat: add VK (VKontakte) as a platform Token-based connect (community or user token) through the 2.0 confirmation page, wall publishing with photo/video upload, platform enums and media rules, composer preview, AI style note and lang entries in all 16 locales. VK is excluded from analytics like LinkedIn and Telegram; collectors are a follow-up. Reworked for the post_platforms-into-posts merge: VkPublisher takes the post itself and reads its single channel. --- app/Enums/PostPlatform/ContentType.php | 10 +- app/Enums/SocialAccount/Platform.php | 15 +- app/Exceptions/Social/VkPublishException.php | 60 ++++ .../Auth/SocialConnectionController.php | 1 + app/Http/Controllers/Auth/VkController.php | 319 ++++++++++++++++++ app/Jobs/PublishToSocialPlatform.php | 4 +- app/Models/SocialAccount.php | 3 + app/Services/Media/MediaOptimizer.php | 6 + app/Services/Social/ConnectionVerifier.php | 28 ++ app/Services/Social/Vk/VkApi.php | 36 ++ app/Services/Social/VkPublisher.php | 274 +++++++++++++++ config/trypost.php | 5 + database/factories/PostFactory.php | 5 + database/factories/SocialAccountFactory.php | 15 + lang/ar/accounts.php | 21 ++ lang/ar/channels.php | 1 + lang/ar/posts.php | 4 + lang/de/accounts.php | 21 ++ lang/de/channels.php | 1 + lang/de/posts.php | 4 + lang/el/accounts.php | 21 ++ lang/el/channels.php | 1 + lang/el/posts.php | 4 + lang/en/accounts.php | 21 ++ lang/en/channels.php | 1 + lang/en/posts.php | 4 + lang/es/accounts.php | 21 ++ lang/es/channels.php | 1 + lang/es/posts.php | 4 + lang/fr/accounts.php | 21 ++ lang/fr/channels.php | 1 + lang/fr/posts.php | 4 + lang/it/accounts.php | 21 ++ lang/it/channels.php | 1 + lang/it/posts.php | 4 + lang/ja/accounts.php | 21 ++ lang/ja/channels.php | 1 + lang/ja/posts.php | 4 + lang/ko/accounts.php | 21 ++ lang/ko/channels.php | 1 + lang/ko/posts.php | 4 + lang/nl/accounts.php | 21 ++ lang/nl/channels.php | 1 + lang/nl/posts.php | 4 + lang/pl/accounts.php | 21 ++ lang/pl/channels.php | 1 + lang/pl/posts.php | 4 + lang/pt-BR/accounts.php | 21 ++ lang/pt-BR/channels.php | 1 + lang/pt-BR/posts.php | 4 + lang/ru/accounts.php | 21 ++ lang/ru/channels.php | 1 + lang/ru/posts.php | 4 + lang/tr/accounts.php | 21 ++ lang/tr/channels.php | 1 + lang/tr/posts.php | 4 + lang/uk/accounts.php | 21 ++ lang/uk/channels.php | 1 + lang/uk/posts.php | 4 + lang/zh/accounts.php | 21 ++ lang/zh/channels.php | 1 + lang/zh/posts.php | 4 + public/images/accounts/vk.png | Bin 0 -> 570 bytes .../posts/previews/PlatformPreview.vue | 3 + .../components/posts/previews/VkPreview.vue | 133 ++++++++ resources/js/composables/useNetworkConnect.ts | 2 + resources/js/composables/usePlatformLogo.ts | 3 + resources/js/pages/accounts/VkConnect.vue | 104 ++++++ resources/js/types/content-type.ts | 1 + resources/js/types/platform.ts | 1 + .../post_content/_platform_style.blade.php | 3 + routes/app.php | 3 + .../Social/ConnectionVerifierTest.php | 43 +++ .../Services/Social/VkPublisherTest.php | 118 +++++++ tests/Feature/Social/VkControllerTest.php | 269 +++++++++++++++ tests/Unit/Enums/ContentTypeTest.php | 8 + tests/Unit/Enums/PlatformTest.php | 7 + .../Social/VkPublishExceptionTest.php | 50 +++ 78 files changed, 1942 insertions(+), 3 deletions(-) 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 e68193aad8..fcdbf47d6f 100644 --- a/app/Enums/PostPlatform/ContentType.php +++ b/app/Enums/PostPlatform/ContentType.php @@ -62,6 +62,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 — @@ -102,6 +104,7 @@ public function label(): string self::TelegramPost => 'Post', self::DiscordMessage => 'Message', self::GoogleBusinessPost => 'Post', + self::VkPost => 'Post', }; } @@ -127,6 +130,7 @@ public function platform(): SocialPlatform self::TelegramPost => SocialPlatform::Telegram, self::DiscordMessage => SocialPlatform::Discord, self::GoogleBusinessPost => SocialPlatform::GoogleBusiness, + self::VkPost => SocialPlatform::Vk, }; } @@ -197,6 +201,7 @@ public function maxMediaCount(): int self::TelegramPost => 10, self::DiscordMessage => 10, self::GoogleBusinessPost => 1, + self::VkPost => 10, }; } @@ -454,7 +459,7 @@ public function cropPresets(): array self::ThreadsPost, self::ThreadsGhostPost => ['1:1', '4:5', '1.91:1'], self::BlueskyPost, self::MastodonPost => ['1:1', '16:9'], self::GoogleBusinessPost => ['4:3', '1:1'], - self::TelegramPost, self::DiscordMessage => self::defaultCropPresets(), + self::TelegramPost, self::DiscordMessage, self::VkPost => self::defaultCropPresets(), }; } @@ -713,6 +718,7 @@ public function supportsVideo(): bool self::TelegramPost => true, self::DiscordMessage => true, self::GoogleBusinessPost => false, + self::VkPost => true, }; } @@ -785,6 +791,7 @@ public function requiresMedia(): bool self::FacebookPost => false, self::DiscordMessage => false, self::GoogleBusinessPost => false, + self::VkPost => false, default => true, }; } @@ -846,6 +853,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 20e3ff754e..479defa53f 100644 --- a/app/Enums/SocialAccount/Platform.php +++ b/app/Enums/SocialAccount/Platform.php @@ -27,6 +27,7 @@ enum Platform: string case Telegram = 'telegram'; case Discord = 'discord'; case GoogleBusiness = 'google_business'; + case Vk = 'vk'; public function network(): string { @@ -47,7 +48,7 @@ public function isIncludedInAnalytics(): bool { return match ($this) { self::LinkedIn, self::LinkedInPage, self::Telegram, - self::Discord, self::GoogleBusiness => false, + self::Discord, self::GoogleBusiness, self::Vk => false, default => true, }; } @@ -98,6 +99,7 @@ public function label(): string self::Telegram => 'Telegram', self::Discord => 'Discord', self::GoogleBusiness => 'Google Business Profile', + self::Vk => 'VK', }; } @@ -130,6 +132,7 @@ public function color(): string self::Telegram => '#26A5E4', self::Discord => '#5865F2', self::GoogleBusiness => '#4285F4', + self::Vk => '#0077FF', }; } @@ -149,6 +152,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], }; } @@ -168,6 +172,7 @@ public function maxImages(): int self::Telegram => 10, self::Discord => 10, self::GoogleBusiness => 1, + self::Vk => 10, }; } @@ -192,6 +197,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, }; } @@ -225,6 +231,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 { @@ -242,6 +249,7 @@ public function maxContentLength(): int self::Telegram => 4096, self::Discord => 2000, self::GoogleBusiness => 1500, + self::Vk => 15895, }; } @@ -321,6 +329,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, }; } @@ -345,6 +355,7 @@ public function requiredPublishScopes(): array self::Telegram => [], self::Discord => [], self::GoogleBusiness => ['https://www.googleapis.com/auth/business.manage'], + self::Vk => [], }; } @@ -373,6 +384,7 @@ public function supportsTextOnly(): bool self::Telegram => true, self::Discord => true, self::GoogleBusiness => true, + self::Vk => true, }; } @@ -512,6 +524,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/Exceptions/Social/VkPublishException.php b/app/Exceptions/Social/VkPublishException.php new file mode 100644 index 0000000000..ee86ca74c5 --- /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/SocialConnectionController.php b/app/Http/Controllers/Auth/SocialConnectionController.php index c0ee853964..754981622e 100644 --- a/app/Http/Controllers/Auth/SocialConnectionController.php +++ b/app/Http/Controllers/Auth/SocialConnectionController.php @@ -44,6 +44,7 @@ private function network(Platform $platform): SocialController Platform::Mastodon => MastodonController::class, Platform::Discord => DiscordController::class, Platform::GoogleBusiness => GoogleBusinessController::class, + Platform::Vk => VkController::class, Platform::LinkedInPage, Platform::Telegram => abort(SymfonyResponse::HTTP_NOT_FOUND), }); } diff --git a/app/Http/Controllers/Auth/VkController.php b/app/Http/Controllers/Auth/VkController.php new file mode 100644 index 0000000000..dd6186d646 --- /dev/null +++ b/app/Http/Controllers/Auth/VkController.php @@ -0,0 +1,319 @@ +ensurePlatformEnabled(); + + $workspace = $request->user()->currentWorkspace; + + $this->authorize('manageAccounts', $workspace); + + $this->rememberConnectSession($request, $workspace); + + return Inertia::render('accounts/VkConnect', [ + 'backUrl' => PendingConnection::current()?->returnUrl() ?? PendingConnection::defaultReturnUrl(), + ]); + } + + public function store(Request $request): RedirectResponse|InertiaResponse + { + $this->ensurePlatformEnabled(); + + $request->validate([ + 'access_token' => 'required|string|min:10', + 'community' => 'nullable|string|max:255', + ]); + + $workspace = $request->user()->currentWorkspace; + + $this->authorize('manageAccounts', $workspace); + + try { + $user = $this->fetchTokenUser($request->access_token); + + if ($user === null) { + // 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', [ + 'communityToken' => true, + 'backUrl' => PendingConnection::current()?->returnUrl() ?? PendingConnection::defaultReturnUrl(), + ]); + } + + return $this->offerCommunityIdentity($request, $workspace); + } + + return $this->offerIdentities( + $workspace, + $this->tokenIdentities($request->access_token, $user), + $this->reconnectAccount($workspace), + ); + } 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')]); + } + } + + /** + * 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). + * 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)) { + // 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; + } + + /** + * Offer 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.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 offerCommunityIdentity(Request $request, Workspace $workspace): RedirectResponse + { + $groups = $this->callVk($request->access_token, 'groups.getById', [ + 'group_ids' => $this->normalizeCommunity((string) $request->community), + 'fields' => 'screen_name,photo_200', + ]); + + // v5.199 returns response.groups[], older versions response[]. + $group = data_get($groups, 'groups.0') ?? data_get($groups, '0'); + + if (! is_array($group)) { + throw ValidationException::withMessages(['community' => __('accounts.vk.invalid_community')]); + } + + $mismatch = Http::asForm()->post(VkApi::endpoint('groups.getCallbackConfirmationCode'), [ + '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'); + + return $this->offerIdentities($workspace, [ + PendingConnection::identity( + $this->platform, + (string) $ownerId, + (string) data_get($group, 'name'), + data_get($group, 'screen_name'), + data_get($group, 'photo_200'), + IdentityType::Page->value, + $this->accountAttributes($request->access_token, [ + 'owner_id' => $ownerId, + 'is_group' => true, + 'community_token' => true, + ], data_get($group, 'screen_name'), (string) data_get($group, 'name')), + ), + ], $this->reconnectAccount($workspace)); + } + + /** + * Walls a user token may publish to — the user's own profile plus + * communities where the user is an administrator or editor — as + * confirmation-page identities. + * + * @param array $user + * @return list> + */ + private function tokenIdentities(string $accessToken, array $user): array + { + $userId = (int) data_get($user, 'id'); + $userName = trim(data_get($user, 'first_name', '').' '.data_get($user, 'last_name', '')); + + $identities = [ + PendingConnection::identity( + $this->platform, + (string) $userId, + $userName, + data_get($user, 'screen_name'), + data_get($user, 'photo_200'), + IdentityType::Profile->value, + $this->accountAttributes($accessToken, [ + 'owner_id' => $userId, + 'is_group' => false, + 'vk_user_id' => $userId, + ], data_get($user, 'screen_name'), $userName), + ), + ]; + + $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'); + + $identities[] = PendingConnection::identity( + $this->platform, + (string) -$groupId, + (string) data_get($group, 'name'), + data_get($group, 'screen_name'), + data_get($group, 'photo_200'), + IdentityType::Page->value, + $this->accountAttributes($accessToken, [ + 'owner_id' => -$groupId, + 'is_group' => true, + 'vk_user_id' => $userId, + ], data_get($group, 'screen_name'), (string) data_get($group, 'name')), + ); + } + + return $identities; + } + + /** + * Column values the shared confirmation flow stores for a picked wall. + * vkhost/standalone and community tokens are issued with the `offline` + * scope and never expire; there is no refresh flow. + * + * @param array $meta + * @return array + */ + private function accountAttributes(string $accessToken, array $meta, ?string $username, string $displayName): array + { + return [ + 'username' => $username, + 'display_name' => $displayName, + 'access_token' => $accessToken, + 'refresh_token' => null, + 'token_expires_at' => null, + 'meta' => $meta, + ]; + } + + /** + * 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 90110b27bc..bc7f844b51 100644 --- a/app/Jobs/PublishToSocialPlatform.php +++ b/app/Jobs/PublishToSocialPlatform.php @@ -30,6 +30,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; @@ -558,7 +559,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->post->platform) { SocialPlatform::LinkedIn => app(LinkedInPublisher::class), @@ -575,6 +576,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 e74c1edfe1..00798cc5ad 100644 --- a/app/Models/SocialAccount.php +++ b/app/Models/SocialAccount.php @@ -357,6 +357,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 15c8998002..4f744d5b42 100644 --- a/app/Services/Media/MediaOptimizer.php +++ b/app/Services/Media/MediaOptimizer.php @@ -317,6 +317,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 54ed6ca77e..9d3391634c 100644 --- a/app/Services/Social/ConnectionVerifier.php +++ b/app/Services/Social/ConnectionVerifier.php @@ -16,6 +16,7 @@ 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; @@ -24,6 +25,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\Http\Client\Response; use Illuminate\Support\Facades\Cache; @@ -217,6 +219,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), }; } @@ -872,4 +875,29 @@ private function verifyGoogleBusiness(SocialAccount $account): bool $response->status(), ); } + + 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( + VkApi::endpoint($method), + 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 0000000000..6d5b9602e3 --- /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 0000000000..2675457c5f --- /dev/null +++ b/app/Services/Social/VkPublisher.php @@ -0,0 +1,274 @@ +validateContentLength($post); + + $content = app(ContentSanitizer::class)->forPost($post); + + $account = $post->socialAccount; + $ownerId = $this->ownerId($account); + + $attachments = []; + + foreach ($post->mediaItems->take($post->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 a682d06aae..f61d2154a6 100644 --- a/config/trypost.php +++ b/config/trypost.php @@ -527,6 +527,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/PostFactory.php b/database/factories/PostFactory.php index 61e6ed4e1e..e6b42957f4 100644 --- a/database/factories/PostFactory.php +++ b/database/factories/PostFactory.php @@ -208,6 +208,11 @@ public function discord(): static return $this->onNetwork(Platform::Discord, ContentType::DiscordMessage, 'discord'); } + public function vk(): static + { + return $this->onNetwork(Platform::Vk, ContentType::VkPost, 'vk'); + } + public function telegram(): static { return $this->onNetwork(Platform::Telegram, ContentType::defaultFor(Platform::Telegram), 'telegram'); diff --git a/database/factories/SocialAccountFactory.php b/database/factories/SocialAccountFactory.php index 0f56bc9e3c..d9f120e855 100644 --- a/database/factories/SocialAccountFactory.php +++ b/database/factories/SocialAccountFactory.php @@ -144,6 +144,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 39eb84706b..71a2ff315c 100644 --- a/lang/ar/accounts.php +++ b/lang/ar/accounts.php @@ -191,4 +191,25 @@ 'location_not_found' => 'الموقع غير موجود.', ], ], + 'vk' => [ + 'title' => 'ربط VK', + 'description' => 'انشر في مجتمع أو على حائطك الشخصي', + 'access_token' => 'رمز الوصول', + 'access_token_placeholder' => 'vk1.a.…', + 'access_token_hint' => 'موصى به: مفتاح وصول المجتمع (المجتمع ← الإدارة ← استخدام API ← مفاتيح الوصول؛ امنح الصور والحائط وإدارة المجتمع) — يعمل النشر مع أي نوع تطبيق. لا يسمح VK برفع الفيديو بمفاتيح المجتمع. يعمل أيضًا مفتاح المستخدم بصلاحيات wall, photos, groups, video, offline، لكن VK يسمح بالنشر على الحائط لتطبيقات standalone فقط.', + 'pick_target' => 'اختر وجهة النشر', + 'target_group' => 'مجتمع', + '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/ar/channels.php b/lang/ar/channels.php index b2f7a9ef37..abdc049a0b 100644 --- a/lang/ar/channels.php +++ b/lang/ar/channels.php @@ -58,6 +58,7 @@ 'telegram' => 'قناة أو مجموعة', 'discord' => 'خادم', 'google_business' => 'ملف النشاط التجاري', + 'vk' => 'الملف الشخصي أو المجتمع', ], 'details' => [ diff --git a/lang/ar/posts.php b/lang/ar/posts.php index 359071350f..1ac0beab5b 100644 --- a/lang/ar/posts.php +++ b/lang/ar/posts.php @@ -773,6 +773,10 @@ 'label' => 'منشور', 'description' => 'يظهر على ملفك التجاري في البحث والخرائط', ], + 'vk_post' => [ + 'label' => 'منشور', + 'description' => 'منشور نصي مع وسائط اختيارية', + ], ], 'platforms' => [ diff --git a/lang/de/accounts.php b/lang/de/accounts.php index cc84b33421..5f8c37cbfe 100644 --- a/lang/de/accounts.php +++ b/lang/de/accounts.php @@ -193,4 +193,25 @@ 'location_not_found' => 'Standort nicht gefunden.', ], ], + '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' => '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', + '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/de/channels.php b/lang/de/channels.php index fc05258d2d..8e5bf369b3 100644 --- a/lang/de/channels.php +++ b/lang/de/channels.php @@ -58,6 +58,7 @@ 'telegram' => 'Kanal oder Gruppe', 'discord' => 'Server', 'google_business' => 'Unternehmensprofil', + 'vk' => 'Profil oder Community', ], 'details' => [ diff --git a/lang/de/posts.php b/lang/de/posts.php index 34fdd6f910..4f7d01d64d 100644 --- a/lang/de/posts.php +++ b/lang/de/posts.php @@ -775,6 +775,10 @@ 'label' => 'Beitrag', 'description' => 'Wird in deinem Geschäftsprofil in Suche und Karten angezeigt', ], + 'vk_post' => [ + 'label' => 'Beitrag', + 'description' => 'Textbeitrag mit optionalen Medien', + ], ], 'platforms' => [ diff --git a/lang/el/accounts.php b/lang/el/accounts.php index 4957225bab..29dd077b8b 100644 --- a/lang/el/accounts.php +++ b/lang/el/accounts.php @@ -191,4 +191,25 @@ 'location_not_found' => 'Η τοποθεσία δεν βρέθηκε.', ], ], + 'vk' => [ + 'title' => 'Σύνδεση VK', + 'description' => 'Δημοσιεύστε σε κοινότητα ή στον τοίχο σας', + 'access_token' => 'Διακριτικό πρόσβασης', + 'access_token_placeholder' => 'vk1.a.…', + 'access_token_hint' => 'Συνιστάται: ένα διακριτικό πρόσβασης κοινότητας (κοινότητα → Διαχείριση → Χρήση API → Διακριτικά πρόσβασης, με δικαιώματα φωτογραφιών, τοίχου και διαχείρισης κοινότητας) — η δημοσίευση λειτουργεί με κάθε τύπο εφαρμογής. Το VK δεν επιτρέπει μεταφόρτωση βίντεο με διακριτικά κοινότητας. Λειτουργεί και διακριτικό χρήστη με δικαιώματα wall, photos, groups, video, offline, αλλά το VK επιτρέπει δημοσίευση στον τοίχο μόνο σε standalone εφαρμογές.', + 'pick_target' => 'Πού να δημοσιευτεί', + 'target_group' => 'Κοινότητα', + '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/el/channels.php b/lang/el/channels.php index 6e1afcee4a..8a60827981 100644 --- a/lang/el/channels.php +++ b/lang/el/channels.php @@ -58,6 +58,7 @@ 'telegram' => 'Κανάλι ή ομάδα', 'discord' => 'Server', 'google_business' => 'Προφίλ επιχείρησης', + 'vk' => 'Προφίλ ή κοινότητα', ], 'details' => [ diff --git a/lang/el/posts.php b/lang/el/posts.php index 40a5fd543d..8948bebf8d 100644 --- a/lang/el/posts.php +++ b/lang/el/posts.php @@ -773,6 +773,10 @@ 'label' => 'Δημοσίευση', 'description' => 'Εμφανίζεται στο Επιχειρηματικό σας Προφίλ στην Αναζήτηση και τους Χάρτες', ], + 'vk_post' => [ + 'label' => 'Ανάρτηση', + 'description' => 'Ανάρτηση κειμένου με προαιρετικά πολυμέσα', + ], ], 'platforms' => [ diff --git a/lang/en/accounts.php b/lang/en/accounts.php index 45181f1a8c..205d174fd4 100644 --- a/lang/en/accounts.php +++ b/lang/en/accounts.php @@ -191,4 +191,25 @@ 'location_not_found' => 'Location not found.', ], ], + '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' => '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', + '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/en/channels.php b/lang/en/channels.php index 26fe5bcfc5..ac1abb6723 100644 --- a/lang/en/channels.php +++ b/lang/en/channels.php @@ -58,6 +58,7 @@ 'telegram' => 'Channel or group', 'discord' => 'Server', 'google_business' => 'Business profile', + 'vk' => 'Profile or community', ], 'details' => [ diff --git a/lang/en/posts.php b/lang/en/posts.php index d469dee198..a2bf836bc3 100644 --- a/lang/en/posts.php +++ b/lang/en/posts.php @@ -773,6 +773,10 @@ 'label' => 'Post', 'description' => 'Appears on your Business Profile in Search and Maps', ], + 'vk_post' => [ + 'label' => 'Post', + 'description' => 'Text post with optional media', + ], ], 'platforms' => [ diff --git a/lang/es/accounts.php b/lang/es/accounts.php index b18daf7a9f..8e1063a1c8 100644 --- a/lang/es/accounts.php +++ b/lang/es/accounts.php @@ -191,4 +191,25 @@ 'location_not_found' => 'Ubicación no encontrada.', ], ], + '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' => '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', + '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/es/channels.php b/lang/es/channels.php index 4f93c9599f..c1ce7d5109 100644 --- a/lang/es/channels.php +++ b/lang/es/channels.php @@ -58,6 +58,7 @@ 'telegram' => 'Canal o grupo', 'discord' => 'Servidor', 'google_business' => 'Perfil de empresa', + 'vk' => 'Perfil o comunidad', ], 'details' => [ diff --git a/lang/es/posts.php b/lang/es/posts.php index a699ac95fa..21c77db80d 100644 --- a/lang/es/posts.php +++ b/lang/es/posts.php @@ -773,6 +773,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' => [ diff --git a/lang/fr/accounts.php b/lang/fr/accounts.php index dd7d493d77..5b978213c7 100644 --- a/lang/fr/accounts.php +++ b/lang/fr/accounts.php @@ -191,4 +191,25 @@ 'location_not_found' => 'Établissement introuvable.', ], ], + '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' => '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', + '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/fr/channels.php b/lang/fr/channels.php index 17730a4018..13fee123fc 100644 --- a/lang/fr/channels.php +++ b/lang/fr/channels.php @@ -58,6 +58,7 @@ 'telegram' => 'Canal ou groupe', 'discord' => 'Serveur', 'google_business' => 'Fiche d’établissement', + 'vk' => 'Profil ou communauté', ], 'details' => [ diff --git a/lang/fr/posts.php b/lang/fr/posts.php index 721c6040cf..4616867eff 100644 --- a/lang/fr/posts.php +++ b/lang/fr/posts.php @@ -773,6 +773,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' => [ diff --git a/lang/it/accounts.php b/lang/it/accounts.php index 8291998755..5cc44b8539 100644 --- a/lang/it/accounts.php +++ b/lang/it/accounts.php @@ -191,4 +191,25 @@ 'location_not_found' => 'Sede non trovata.', ], ], + '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' => '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', + '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/it/channels.php b/lang/it/channels.php index ffd9b6c358..55d0583afe 100644 --- a/lang/it/channels.php +++ b/lang/it/channels.php @@ -58,6 +58,7 @@ 'telegram' => 'Canale o gruppo', 'discord' => 'Server', 'google_business' => 'Profilo aziendale', + 'vk' => 'Profilo o community', ], 'details' => [ diff --git a/lang/it/posts.php b/lang/it/posts.php index 4a48423f9f..7f9f5c4730 100644 --- a/lang/it/posts.php +++ b/lang/it/posts.php @@ -773,6 +773,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' => [ diff --git a/lang/ja/accounts.php b/lang/ja/accounts.php index 2c9abb051d..35a01c9276 100644 --- a/lang/ja/accounts.php +++ b/lang/ja/accounts.php @@ -191,4 +191,25 @@ 'location_not_found' => 'ビジネス拠点が見つかりません。', ], ], + 'vk' => [ + 'title' => 'VKを連携', + 'description' => 'コミュニティまたは自分のウォールに投稿します', + 'access_token' => 'アクセストークン', + 'access_token_placeholder' => 'vk1.a.…', + 'access_token_hint' => '推奨: コミュニティアクセストークン(コミュニティ → 管理 → API の利用 → アクセストークン。写真・ウォール・コミュニティ管理を許可)— アプリ種別を問わず投稿できます。コミュニティトークンでは VK は動画アップロードを許可していません。wall, photos, groups, video, offline スコープのユーザートークンも使えますが、ウォール投稿は standalone アプリのみ許可されます。', + 'pick_target' => '投稿先を選択', + 'target_group' => 'コミュニティ', + '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/ja/channels.php b/lang/ja/channels.php index 2bd6c1d800..cc9bb1744c 100644 --- a/lang/ja/channels.php +++ b/lang/ja/channels.php @@ -58,6 +58,7 @@ 'telegram' => 'チャンネルまたはグループ', 'discord' => 'サーバー', 'google_business' => 'ビジネスプロフィール', + 'vk' => 'プロフィールまたはコミュニティ', ], 'details' => [ diff --git a/lang/ja/posts.php b/lang/ja/posts.php index 5b76c7947d..8aa383c0fd 100644 --- a/lang/ja/posts.php +++ b/lang/ja/posts.php @@ -773,6 +773,10 @@ 'label' => '投稿', 'description' => 'ビジネス プロフィールに検索とマップで表示されます', ], + 'vk_post' => [ + 'label' => '投稿', + 'description' => 'メディア添付可能なテキスト投稿', + ], ], 'platforms' => [ diff --git a/lang/ko/accounts.php b/lang/ko/accounts.php index 609f9d0d64..6f5854faad 100644 --- a/lang/ko/accounts.php +++ b/lang/ko/accounts.php @@ -191,4 +191,25 @@ 'location_not_found' => '위치를 찾을 수 없습니다.', ], ], + 'vk' => [ + 'title' => 'VK 연결', + 'description' => '커뮤니티 또는 내 담벼락에 게시합니다', + 'access_token' => '액세스 토큰', + 'access_token_placeholder' => 'vk1.a.…', + 'access_token_hint' => '권장: 커뮤니티 액세스 토큰(커뮤니티 → 관리 → API 사용 → 액세스 토큰, 사진·담벼락·커뮤니티 관리 권한 부여) — 앱 유형과 무관하게 게시할 수 있습니다. 커뮤니티 토큰으로는 VK가 동영상 업로드를 허용하지 않습니다. wall, photos, groups, video, offline 권한의 사용자 토큰도 동작하지만, 담벼락 게시는 standalone 앱에만 허용됩니다.', + 'pick_target' => '게시 위치 선택', + 'target_group' => '커뮤니티', + '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/channels.php b/lang/ko/channels.php index 40fc52a849..34f4ef9358 100644 --- a/lang/ko/channels.php +++ b/lang/ko/channels.php @@ -58,6 +58,7 @@ 'telegram' => '채널 또는 그룹', 'discord' => '서버', 'google_business' => '비즈니스 프로필', + 'vk' => '프로필 또는 커뮤니티', ], 'details' => [ diff --git a/lang/ko/posts.php b/lang/ko/posts.php index bc3b612385..8442635a8e 100644 --- a/lang/ko/posts.php +++ b/lang/ko/posts.php @@ -773,6 +773,10 @@ 'label' => '게시물', 'description' => '비즈니스 프로필에 검색 및 지도에 표시됩니다', ], + 'vk_post' => [ + 'label' => '게시물', + 'description' => '미디어를 선택적으로 첨부할 수 있는 텍스트 게시물', + ], ], 'platforms' => [ diff --git a/lang/nl/accounts.php b/lang/nl/accounts.php index be7861198c..b2edfd6df7 100644 --- a/lang/nl/accounts.php +++ b/lang/nl/accounts.php @@ -191,4 +191,25 @@ 'location_not_found' => 'Locatie niet gevonden.', ], ], + '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' => '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', + '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/nl/channels.php b/lang/nl/channels.php index a060e475bc..2152537dce 100644 --- a/lang/nl/channels.php +++ b/lang/nl/channels.php @@ -58,6 +58,7 @@ 'telegram' => 'Kanaal of groep', 'discord' => 'Server', 'google_business' => 'Bedrijfsprofiel', + 'vk' => 'Profiel of community', ], 'details' => [ diff --git a/lang/nl/posts.php b/lang/nl/posts.php index 6616a1edd7..be166e4c48 100644 --- a/lang/nl/posts.php +++ b/lang/nl/posts.php @@ -773,6 +773,10 @@ 'label' => 'Bericht', 'description' => 'Wordt weergegeven in je Bedrijfsprofiel in Zoeken en Kaarten', ], + 'vk_post' => [ + 'label' => 'Bericht', + 'description' => 'Tekstbericht met optionele media', + ], ], 'platforms' => [ diff --git a/lang/pl/accounts.php b/lang/pl/accounts.php index a4ff4125be..ee4d9848dd 100644 --- a/lang/pl/accounts.php +++ b/lang/pl/accounts.php @@ -191,4 +191,25 @@ 'location_not_found' => 'Nie znaleziono lokalizacji.', ], ], + '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' => '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', + '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/pl/channels.php b/lang/pl/channels.php index 630a7abcf5..9cc990062b 100644 --- a/lang/pl/channels.php +++ b/lang/pl/channels.php @@ -58,6 +58,7 @@ 'telegram' => 'Kanał lub grupa', 'discord' => 'Serwer', 'google_business' => 'Profil firmy', + 'vk' => 'Profil lub społeczność', ], 'details' => [ diff --git a/lang/pl/posts.php b/lang/pl/posts.php index 24374ca05c..5e9f1985cc 100644 --- a/lang/pl/posts.php +++ b/lang/pl/posts.php @@ -773,6 +773,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' => [ diff --git a/lang/pt-BR/accounts.php b/lang/pt-BR/accounts.php index 3f6f7d3323..0d36df0755 100644 --- a/lang/pt-BR/accounts.php +++ b/lang/pt-BR/accounts.php @@ -191,4 +191,25 @@ 'location_not_found' => 'Local não encontrado.', ], ], + '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' => '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', + '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/pt-BR/channels.php b/lang/pt-BR/channels.php index 0bad0aa1d3..8ce6c948f0 100644 --- a/lang/pt-BR/channels.php +++ b/lang/pt-BR/channels.php @@ -58,6 +58,7 @@ 'telegram' => 'Canal ou grupo', 'discord' => 'Servidor', 'google_business' => 'Perfil da empresa', + 'vk' => 'Perfil ou comunidade', ], 'details' => [ diff --git a/lang/pt-BR/posts.php b/lang/pt-BR/posts.php index 7d6856cab2..15b7b6d8f0 100644 --- a/lang/pt-BR/posts.php +++ b/lang/pt-BR/posts.php @@ -773,6 +773,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' => [ diff --git a/lang/ru/accounts.php b/lang/ru/accounts.php index ceaf3f000f..6edb1a02ad 100644 --- a/lang/ru/accounts.php +++ b/lang/ru/accounts.php @@ -191,4 +191,25 @@ 'location_not_found' => 'Филиал не найден.', ], ], + 'vk' => [ + 'title' => 'Подключить VK', + 'description' => 'Публикация в сообщество или на свою стену', + 'access_token' => 'Ключ доступа', + 'access_token_placeholder' => 'vk1.a.…', + 'access_token_hint' => 'Рекомендуется ключ доступа сообщества (сообщество → Управление → Работа с API → Ключи доступа; отметьте фотографии, стену и управление сообществом) — публикация работает с любым типом приложения. Загрузку видео VK по ключу сообщества не разрешает. Подойдёт и пользовательский ключ с правами wall, photos, groups, video, offline, но постить на стену VK разрешает только standalone-приложениям.', + 'pick_target' => 'Куда публиковать', + 'target_group' => 'Сообщество', + '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/ru/channels.php b/lang/ru/channels.php index f8d317cfed..bd68cd4881 100644 --- a/lang/ru/channels.php +++ b/lang/ru/channels.php @@ -58,6 +58,7 @@ 'telegram' => 'Канал или группа', 'discord' => 'Сервер', 'google_business' => 'Профиль компании', + 'vk' => 'Профиль или сообщество', ], 'details' => [ diff --git a/lang/ru/posts.php b/lang/ru/posts.php index 7c0d0b9275..02535f3127 100644 --- a/lang/ru/posts.php +++ b/lang/ru/posts.php @@ -773,6 +773,10 @@ 'label' => 'Пост', 'description' => 'Отображается в вашем Профиле компании в Поиске и Картах', ], + 'vk_post' => [ + 'label' => 'Пост', + 'description' => 'Текстовый пост с необязательными медиа', + ], ], 'platforms' => [ diff --git a/lang/tr/accounts.php b/lang/tr/accounts.php index 9d82088de9..168468054a 100644 --- a/lang/tr/accounts.php +++ b/lang/tr/accounts.php @@ -193,4 +193,25 @@ 'location_not_found' => 'Konum bulunamadı.', ], ], + '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' => 'Ö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', + '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/tr/channels.php b/lang/tr/channels.php index 54deb794ee..aa29718c94 100644 --- a/lang/tr/channels.php +++ b/lang/tr/channels.php @@ -58,6 +58,7 @@ 'telegram' => 'Kanal veya grup', 'discord' => 'Sunucu', 'google_business' => 'İşletme profili', + 'vk' => 'Profil veya topluluk', ], 'details' => [ diff --git a/lang/tr/posts.php b/lang/tr/posts.php index d45bd2a3a9..10b3226be7 100644 --- a/lang/tr/posts.php +++ b/lang/tr/posts.php @@ -775,6 +775,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' => [ diff --git a/lang/uk/accounts.php b/lang/uk/accounts.php index 0fce3c21da..74274a245c 100644 --- a/lang/uk/accounts.php +++ b/lang/uk/accounts.php @@ -191,4 +191,25 @@ 'location_not_found' => 'Філію не знайдено.', ], ], + 'vk' => [ + 'title' => 'Підключити VK', + 'description' => 'Публікація у спільноту або на власну стіну', + 'access_token' => 'Ключ доступу', + 'access_token_placeholder' => 'vk1.a.…', + 'access_token_hint' => 'Рекомендовано ключ доступу спільноти (спільнота → Керування → Робота з API → Ключі доступу; позначте фотографії, стіну та керування спільнотою) — публікація працює з будь-яким типом застосунку. Завантаження відео за ключем спільноти VK не дозволяє. Підійде і користувацький ключ із правами wall, photos, groups, video, offline, але постити на стіну VK дозволяє лише standalone-застосункам.', + 'pick_target' => 'Куди публікувати', + 'target_group' => 'Спільнота', + '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/uk/channels.php b/lang/uk/channels.php index 1eca911f79..53103b25fc 100644 --- a/lang/uk/channels.php +++ b/lang/uk/channels.php @@ -58,6 +58,7 @@ 'telegram' => 'Канал або група', 'discord' => 'Сервер', 'google_business' => 'Профіль компанії', + 'vk' => 'Профіль або спільнота', ], 'details' => [ diff --git a/lang/uk/posts.php b/lang/uk/posts.php index 4783f34231..ed76cfcff2 100644 --- a/lang/uk/posts.php +++ b/lang/uk/posts.php @@ -773,6 +773,10 @@ 'label' => 'Публікація', 'description' => 'Відображається у вашому Профілі компанії в Пошуку та Картах', ], + 'vk_post' => [ + 'label' => 'Пост', + 'description' => 'Текстовий пост із необов\'язковими медіа', + ], ], 'platforms' => [ diff --git a/lang/zh/accounts.php b/lang/zh/accounts.php index 90ca4705a4..3c62c56d3c 100644 --- a/lang/zh/accounts.php +++ b/lang/zh/accounts.php @@ -191,4 +191,25 @@ 'location_not_found' => '未找到该商家地点。', ], ], + 'vk' => [ + 'title' => '连接 VK', + 'description' => '发布到社群或您自己的动态墙', + 'access_token' => '访问令牌', + 'access_token_placeholder' => 'vk1.a.…', + 'access_token_hint' => '推荐使用社区访问令牌(社区 → 管理 → API 使用 → 访问令牌;授予照片、留言墙和社区管理权限)—— 任何应用类型都可发布。VK 不允许使用社区令牌上传视频。带有 wall, photos, groups, video, offline 权限的用户令牌也可以,但 VK 仅允许 standalone 应用发布到留言墙。', + 'pick_target' => '选择发布位置', + 'target_group' => '社群', + '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/channels.php b/lang/zh/channels.php index 4a213956fd..5a633d08f1 100644 --- a/lang/zh/channels.php +++ b/lang/zh/channels.php @@ -58,6 +58,7 @@ 'telegram' => '频道或群组', 'discord' => '服务器', 'google_business' => '商家资料', + 'vk' => '个人主页或社区', ], 'details' => [ diff --git a/lang/zh/posts.php b/lang/zh/posts.php index 4de091b987..7f329ffc09 100644 --- a/lang/zh/posts.php +++ b/lang/zh/posts.php @@ -773,6 +773,10 @@ 'label' => '帖子', 'description' => '在搜索和地图中显示在您的商业资料中', ], + 'vk_post' => [ + 'label' => '帖子', + 'description' => '可附带媒体的文字帖子', + ], ], 'platforms' => [ diff --git a/public/images/accounts/vk.png b/public/images/accounts/vk.png new file mode 100644 index 0000000000000000000000000000000000000000..3ad605a5dcb453ad68b26023bd08745950fa49d1 GIT binary patch literal 570 zcmV-A0>%A_P)((!q}9?q1iO3~zcn)T@GaHV_j8MrV_$g$}9Ej!>q8b|w(S zk4_}~W?AdwYuzCYdg>VLysscN;oef0B(Mdr}ao~y{8%T6qq=4u$lg>$+(Ogq2878I?T~mCV z@(K1Nn3zU%^`@d*^(hEqN|%CV%$@A*m&Sx{?XZEMjI5?x)uS?4EC|g~uD@vLD@rg- zEQs!v#g1u?<6whKoCaqTfeZB1K%%|SwTbiTGBJJI^jhYC_b z{wYW?#|p(9^b~XSR>)y!F~`$IT^K0l3P(|wYKpq%l<~#0AKsbyp#PU&_y7O^07*qo IM6N<$g5co?Z~y=R literal 0 HcmV?d00001 diff --git a/resources/js/components/posts/previews/PlatformPreview.vue b/resources/js/components/posts/previews/PlatformPreview.vue index 7956162c1f..9780909832 100644 --- a/resources/js/components/posts/previews/PlatformPreview.vue +++ b/resources/js/components/posts/previews/PlatformPreview.vue @@ -18,6 +18,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 type { PreviewAccount } from './types'; import XPreview from './XPreview.vue'; import YouTubePreview from './YouTubePreview.vue'; @@ -123,6 +124,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 0000000000..2c50171dea --- /dev/null +++ b/resources/js/components/posts/previews/VkPreview.vue @@ -0,0 +1,133 @@ + + + diff --git a/resources/js/composables/useNetworkConnect.ts b/resources/js/composables/useNetworkConnect.ts index c5a51c62be..afed7c30f1 100644 --- a/resources/js/composables/useNetworkConnect.ts +++ b/resources/js/composables/useNetworkConnect.ts @@ -13,6 +13,7 @@ import { connect as mastodonConnect } from '@/routes/app/social/mastodon'; 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 vkConnect } from '@/routes/app/social/vk'; import { connect as xConnect } from '@/routes/app/social/x'; import { connect as youtubeConnect } from '@/routes/app/social/youtube'; import { Platform } from '@/types/platform'; @@ -33,6 +34,7 @@ const CONNECT_ROUTES: Record< [Platform.Pinterest]: pinterestConnect, [Platform.Threads]: threadsConnect, [Platform.TikTok]: tiktokConnect, + [Platform.Vk]: vkConnect, [Platform.X]: xConnect, [Platform.YouTube]: youtubeConnect, }; diff --git a/resources/js/composables/usePlatformLogo.ts b/resources/js/composables/usePlatformLogo.ts index 39a0591767..a69f89fbf7 100644 --- a/resources/js/composables/usePlatformLogo.ts +++ b/resources/js/composables/usePlatformLogo.ts @@ -16,6 +16,7 @@ const PLATFORM_LOGOS: 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 = { @@ -34,6 +35,7 @@ const PLATFORM_LABELS: Record = { telegram: 'Telegram', discord: 'Discord', google_business: 'Google Business Profile', + vk: 'VK', }; const PLATFORM_CONTENT_TYPES: Record = { @@ -56,6 +58,7 @@ const PLATFORM_CONTENT_TYPES: Record = { telegram: ['telegram_post'], discord: ['discord_message'], google_business: ['google_business_post'], + vk: ['vk_post'], }; const PLATFORM_CONTENT_TYPE_BADGES: Record = { diff --git a/resources/js/pages/accounts/VkConnect.vue b/resources/js/pages/accounts/VkConnect.vue new file mode 100644 index 0000000000..766bfd40ec --- /dev/null +++ b/resources/js/pages/accounts/VkConnect.vue @@ -0,0 +1,104 @@ + + + diff --git a/resources/js/types/content-type.ts b/resources/js/types/content-type.ts index 274947504d..f64ee61756 100644 --- a/resources/js/types/content-type.ts +++ b/resources/js/types/content-type.ts @@ -21,6 +21,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 d0744d1a46..67e4031b59 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/resources/views/prompts/post_content/_platform_style.blade.php b/resources/views/prompts/post_content/_platform_style.blade.php index bf04b3f563..f8ebcd7240 100644 --- a/resources/views/prompts/post_content/_platform_style.blade.php +++ b/resources/views/prompts/post_content/_platform_style.blade.php @@ -36,4 +36,7 @@ @case('google_business') - Scannable local-business update with one call to action, no hashtags. @break +@case('vk') +- Conversational wall post; a few hashtags at the end are fine. +@break @endswitch diff --git a/routes/app.php b/routes/app.php index d9083f9559..a90272058a 100644 --- a/routes/app.php +++ b/routes/app.php @@ -54,6 +54,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; @@ -119,6 +120,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/ConnectionVerifierTest.php b/tests/Feature/Services/Social/ConnectionVerifierTest.php index 6ec1f3aa7a..52ea4e84db 100644 --- a/tests/Feature/Services/Social/ConnectionVerifierTest.php +++ b/tests/Feature/Services/Social/ConnectionVerifierTest.php @@ -1510,6 +1510,49 @@ 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')); +}); + test('a refresh that loses the race reuses the token the winner stored instead of replaying a used refresh token', function () { Http::fake([ config('trypost.platforms.x.api').'/*' => Http::response(['error' => 'invalid_request'], 400), diff --git a/tests/Feature/Services/Social/VkPublisherTest.php b/tests/Feature/Services/Social/VkPublisherTest.php new file mode 100644 index 0000000000..f89cae6a1c --- /dev/null +++ b/tests/Feature/Services/Social/VkPublisherTest.php @@ -0,0 +1,118 @@ +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()->vk()->create([ + 'workspace_id' => $this->workspace->id, + 'user_id' => $this->user->id, + 'social_account_id' => $this->socialAccount->id, + 'content' => 'Hello from VK!', + ]); + + $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->post); + + 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->post->refresh(); + + Http::fake([ + "{$this->api}/wall.post*" => Http::response([ + 'response' => ['post_id' => 7], + ], 200), + ]); + + $result = $this->publisher->publish($this->post); + + 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->post); +})->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->post); + $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', $this->socialAccount->maxContentLength() + 1)]); + $this->post->refresh(); + + Http::fake(); + + $this->publisher->publish($this->post); +})->throws(Exception::class, 'VK allows'); diff --git a/tests/Feature/Social/VkControllerTest.php b/tests/Feature/Social/VkControllerTest.php new file mode 100644 index 0000000000..c96433e759 --- /dev/null +++ b/tests/Feature/Social/VkControllerTest.php @@ -0,0 +1,269 @@ +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, membershipPivot('member')); + + $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('validation errors reach the connect page as strings, not arrays', function () { + $this->actingAs($this->user) + ->from(route('app.social.vk.connect')) + ->post(route('app.social.vk.store'), []) + ->assertRedirect(route('app.social.vk.connect')); + + $this->actingAs($this->user) + ->get(route('app.social.vk.connect')) + ->assertInertia(fn (AssertableInertia $page) => $page + ->component('accounts/VkConnect') + ->where('errors.access_token', fn ($message) => is_string($message) && $message !== '')); +}); + +test('submitting a valid token offers the manageable walls on the confirmation page', function () { + Http::fake(fakeVkIdentity($this->api)); + + $this->actingAs($this->user)->get(route('app.social.vk.connect')); + + $response = $this->actingAs($this->user)->post(route('app.social.vk.store'), [ + 'access_token' => 'vk1.a.valid-test-token', + ]); + + $response->assertRedirect(route('app.social.connect.show', Platform::Vk)); + + expect(PendingConnection::current()->identityKeys()) + ->toBe(['vk:111', 'vk:-123456']); + + $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)); + + $this->actingAs($this->user)->get(route('app.social.vk.connect')); + + $this->actingAs($this->user)->post(route('app.social.vk.store'), [ + 'access_token' => 'vk1.a.valid-test-token', + ])->assertRedirect(route('app.social.connect.show', Platform::Vk)); + + finishSocialConnect(Platform::Vk, ['vk:-123456'])->assertRedirect(); + + $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, + ]); + + $account = $this->workspace->socialAccounts()->where('platform', Platform::Vk->value)->sole(); + + expect(data_get($account->meta, 'owner_id'))->toBe(-123456) + ->and(data_get($account->meta, 'is_group'))->toBeTrue() + ->and(data_get($account->meta, 'vk_user_id'))->toBe(111); +}); + +test('finish refuses a wall the token was never offered', function () { + Http::fake(fakeVkIdentity($this->api)); + + $this->actingAs($this->user)->get(route('app.social.vk.connect')); + + $this->actingAs($this->user)->post(route('app.social.vk.store'), [ + 'access_token' => 'vk1.a.valid-test-token', + ])->assertRedirect(route('app.social.connect.show', Platform::Vk)); + + finishSocialConnect(Platform::Vk, ['vk:-999999']) + ->assertSessionHasErrors('identities.0'); + + $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'); +}); + +function fakeVkCommunityToken(string $api): array +{ + return [ + // users.get accepts a community key too, but without user_ids it + // answers with an empty list — that is how a community key is told + // apart from a user token. + "{$api}/users.get*" => Http::response(['response' => []], 200), + "{$api}/groups.getById*" => Http::response([ + 'response' => [ + 'groups' => [ + [ + 'id' => 654321, + 'name' => 'NJ Soft', + 'screen_name' => 'njsoft', + 'photo_200' => null, + ], + ], + ], + ], 200), + "{$api}/groups.getCallbackConfirmationCode*" => Http::response([ + 'response' => ['code' => '0f3f31b6'], + ], 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)); + + $this->actingAs($this->user)->get(route('app.social.vk.connect')); + + $this->actingAs($this->user)->post(route('app.social.vk.store'), [ + 'access_token' => 'vk1.a.community-token', + 'community' => 'https://vk.com/njsoft', + ])->assertRedirect(route('app.social.connect.show', Platform::Vk)); + + Http::assertSent(fn ($request) => str_contains($request->url(), '/groups.getById') + && $request['group_ids'] === 'njsoft'); + + finishSocialConnect(Platform::Vk)->assertRedirect(); + + $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)->sole(); + + 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 of a different community is rejected', function () { + Http::fake(array_merge(fakeVkCommunityToken($this->api), [ + "{$this->api}/groups.getCallbackConfirmationCode*" => Http::response([ + 'error' => [ + 'error_code' => 15, + 'error_msg' => 'Access denied: no access to 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([ + 'response' => ['groups' => []], + ], 200), + ])); + + $response = $this->actingAs($this->user)->post(route('app.social.vk.store'), [ + 'access_token' => 'vk1.a.community-token', + 'community' => 'no-such-club', + ]); + + $response->assertSessionHasErrors('community'); + + $this->assertDatabaseMissing('social_accounts', [ + 'workspace_id' => $this->workspace->id, + 'platform' => Platform::Vk->value, + ]); +}); diff --git a/tests/Unit/Enums/ContentTypeTest.php b/tests/Unit/Enums/ContentTypeTest.php index 8d07b1f78c..3dc6f69129 100644 --- a/tests/Unit/Enums/ContentTypeTest.php +++ b/tests/Unit/Enums/ContentTypeTest.php @@ -274,6 +274,14 @@ 'max_image_bytes' => min(5 * $mb, $hardImage), 'max_video_bytes' => min(20 * $mb, $hardVideo), ], + // VK wall posts may be text-only; attachments are optional (up to 10). + 'vk_post' => [ + 'requires_media' => false, + 'accepts_gif' => false, + 'max_files' => 10, + 'max_image_bytes' => null, + 'max_video_bytes' => null, + ], 'tiktok_video' => [ 'requires_media' => true, 'accepts_gif' => false, diff --git a/tests/Unit/Enums/PlatformTest.php b/tests/Unit/Enums/PlatformTest.php index 6f89da0fcb..20296ba0eb 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 () { @@ -122,6 +124,7 @@ Platform::Telegram, Platform::Discord, Platform::GoogleBusiness, + Platform::Vk, ]); test('each platform can be disabled via config', function (Platform $platform) { @@ -144,6 +147,7 @@ Platform::Telegram, Platform::Discord, Platform::GoogleBusiness, + Platform::Vk, ]); test('each platform maps to its publishing queue', function (Platform $platform, string $queue) { @@ -164,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 () { @@ -183,6 +188,7 @@ 'social-telegram', 'social-discord', 'social-google_business', + 'social-vk', ])->and(Platform::allQueues())->toHaveCount(count(Platform::cases())); }); @@ -232,6 +238,7 @@ Platform::Telegram, Platform::Discord, Platform::GoogleBusiness, + Platform::Vk, ]); test('disabling every platform yields no enabled queues', function () { diff --git a/tests/Unit/Exceptions/Social/VkPublishExceptionTest.php b/tests/Unit/Exceptions/Social/VkPublishExceptionTest.php new file mode 100644 index 0000000000..5a2c9eeab7 --- /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 c0aab65677ecceb14ae2d45c77fb336c680b1230 Mon Sep 17 00:00:00 2001 From: Grigory Frolov <2168057+gynsus@users.noreply.github.com> Date: Sun, 11 Oct 2026 11:56:03 +0300 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20VK=20analytics=20collectors=20?= =?UTF-8?q?=E2=80=94=20followers,=20publication=20history=20and=20metrics?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit VK joins the analytics-included platforms. Followers come from groups.getById(members_count) for a community wall and users.get(followers_count) for a profile; the wall history pages wall.get by offset (reposts skipped, a pinned post never ends the window); per-post metrics read wall.getById views/likes/reposts/ comments. VkAnalyticsResponse unwraps VK's 200-with-error bodies onto the analytics exception categories (5 = authentication, 6/9/29 = rate limited, 18 = gone) and collectors authenticate through the Bearer header, keeping the token out of the query string. A community token cannot read walls at all — VK answers error 27 to wall.get, wall.getById and stats.* alike (verified live), so for those accounts only followers are measured: history returns an empty page with a partial reason and post metrics refuse with a permission error before spending a request. The connect hint now says community tokens exclude post analytics, in all 16 locales. --- app/Enums/SocialAccount/Platform.php | 2 +- .../Followers/FollowerCollectorFactory.php | 1 + .../Followers/VkFollowerCollector.php | 40 +++++ .../PublicationMetricsCollectorFactory.php | 1 + .../Metrics/VkPublicationMetricsCollector.php | 52 ++++++ .../PublicationHistoryCollectorFactory.php | 1 + .../Publications/VkPublicationCollector.php | 151 ++++++++++++++++++ app/Services/Social/Vk/VkApi.php | 10 ++ app/Support/Analytics/VkAnalyticsResponse.php | 59 +++++++ 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 +- .../Collectors/FollowerCollectorsTest.php | 37 +++++ .../OpenPublicationCollectorsTest.php | 91 +++++++++++ .../PublicationMetricsCollectorsTest.php | 95 +++++++++++ 28 files changed, 555 insertions(+), 17 deletions(-) create mode 100644 app/Services/Analytics/Collectors/Followers/VkFollowerCollector.php create mode 100644 app/Services/Analytics/Collectors/Metrics/VkPublicationMetricsCollector.php create mode 100644 app/Services/Analytics/Collectors/Publications/VkPublicationCollector.php create mode 100644 app/Support/Analytics/VkAnalyticsResponse.php diff --git a/app/Enums/SocialAccount/Platform.php b/app/Enums/SocialAccount/Platform.php index 479defa53f..4405c7bd75 100644 --- a/app/Enums/SocialAccount/Platform.php +++ b/app/Enums/SocialAccount/Platform.php @@ -48,7 +48,7 @@ public function isIncludedInAnalytics(): bool { return match ($this) { self::LinkedIn, self::LinkedInPage, self::Telegram, - self::Discord, self::GoogleBusiness, self::Vk => false, + self::Discord, self::GoogleBusiness => false, default => true, }; } diff --git a/app/Services/Analytics/Collectors/Followers/FollowerCollectorFactory.php b/app/Services/Analytics/Collectors/Followers/FollowerCollectorFactory.php index 2717c31566..ee308c179a 100644 --- a/app/Services/Analytics/Collectors/Followers/FollowerCollectorFactory.php +++ b/app/Services/Analytics/Collectors/Followers/FollowerCollectorFactory.php @@ -26,6 +26,7 @@ public function for(Platform $platform): AbstractFollowerCollector Platform::TikTok => app(TikTokFollowerCollector::class), Platform::Bluesky => app(BlueskyFollowerCollector::class), Platform::Mastodon => app(MastodonFollowerCollector::class), + Platform::Vk => app(VkFollowerCollector::class), default => throw AnalyticsCollectionException::unsupported("{$platform->value} follower analytics is excluded"), }; } diff --git a/app/Services/Analytics/Collectors/Followers/VkFollowerCollector.php b/app/Services/Analytics/Collectors/Followers/VkFollowerCollector.php new file mode 100644 index 0000000000..2c6a6d0099 --- /dev/null +++ b/app/Services/Analytics/Collectors/Followers/VkFollowerCollector.php @@ -0,0 +1,40 @@ +get($account, VkApi::endpoint('groups.getById'), [ + 'group_ids' => abs($ownerId), + 'fields' => 'members_count', + 'v' => VkApi::version(), + ]), 'follower collection'); + + // v5.199 returns response.groups[], older versions response[]. + $group = data_get($data, 'groups.0') ?? data_get($data, '0'); + + return $this->observation($date, data_get($group, 'members_count')); + } + + $data = VkAnalyticsResponse::data($this->get($account, VkApi::endpoint('users.get'), [ + 'user_ids' => $ownerId, + 'fields' => 'followers_count', + 'v' => VkApi::version(), + ]), 'follower collection'); + + return $this->observation($date, data_get($data, '0.followers_count')); + } +} diff --git a/app/Services/Analytics/Collectors/Metrics/PublicationMetricsCollectorFactory.php b/app/Services/Analytics/Collectors/Metrics/PublicationMetricsCollectorFactory.php index 2953f833c0..3e22f712c5 100644 --- a/app/Services/Analytics/Collectors/Metrics/PublicationMetricsCollectorFactory.php +++ b/app/Services/Analytics/Collectors/Metrics/PublicationMetricsCollectorFactory.php @@ -21,6 +21,7 @@ public function for(Platform $platform): AbstractPublicationMetricsCollector Platform::TikTok => app(TikTokPublicationMetricsCollector::class), Platform::Bluesky => app(BlueskyPublicationMetricsCollector::class), Platform::Mastodon => app(MastodonPublicationMetricsCollector::class), + Platform::Vk => app(VkPublicationMetricsCollector::class), default => throw AnalyticsCollectionException::unsupported("{$platform->value} publication metrics are excluded"), }; } diff --git a/app/Services/Analytics/Collectors/Metrics/VkPublicationMetricsCollector.php b/app/Services/Analytics/Collectors/Metrics/VkPublicationMetricsCollector.php new file mode 100644 index 0000000000..29aa426937 --- /dev/null +++ b/app/Services/Analytics/Collectors/Metrics/VkPublicationMetricsCollector.php @@ -0,0 +1,52 @@ +account($publication); + + if (filled(data_get($account->meta, 'community_token'))) { + // wall.getById answers error 27 for a community token, like every + // other wall read; only a user token can measure posts. + throw new AnalyticsCollectionException( + 'permission', + 'VK wall reads are unavailable with a community token; reconnect the channel with a user token', + ); + } + + $ownerId = VkAnalyticsResponse::ownerId($account); + + $data = VkAnalyticsResponse::data($this->get($account, VkApi::endpoint('wall.getById'), [ + 'posts' => "{$ownerId}_{$publication->remote_id}", + 'v' => VkApi::version(), + ]), 'publication metrics collection'); + + // v5.199 returns response.items[], older versions response[]. + $post = data_get($data, 'items.0') ?? data_get($data, '0'); + + if (! is_array($post)) { + // wall.getById answers 200 with an empty list for a deleted post. + throw AnalyticsCollectionException::gone('VK post no longer exists'); + } + + return $this->observation($date, $this->withEngagements($this->present([ + $this->count(MetricKey::Views, $post, 'views.count'), + $this->count(MetricKey::Reactions, $post, 'likes.count'), + $this->count(MetricKey::Comments, $post, 'comments.count'), + $this->count(MetricKey::Shares, $post, 'reposts.count'), + ]))); + } +} diff --git a/app/Services/Analytics/Collectors/Publications/PublicationHistoryCollectorFactory.php b/app/Services/Analytics/Collectors/Publications/PublicationHistoryCollectorFactory.php index ef6c597c5b..7aa9370952 100644 --- a/app/Services/Analytics/Collectors/Publications/PublicationHistoryCollectorFactory.php +++ b/app/Services/Analytics/Collectors/Publications/PublicationHistoryCollectorFactory.php @@ -27,6 +27,7 @@ public function for(SocialAccount $account): AbstractPublicationHistoryCollector Platform::TikTok => app(TikTokPublicationCollector::class), Platform::Bluesky => app(BlueskyPublicationCollector::class), Platform::Mastodon => app(MastodonPublicationCollector::class), + Platform::Vk => app(VkPublicationCollector::class), default => throw AnalyticsCollectionException::unsupported("{$account->platform->value} publication history is not supported"), }; } diff --git a/app/Services/Analytics/Collectors/Publications/VkPublicationCollector.php b/app/Services/Analytics/Collectors/Publications/VkPublicationCollector.php new file mode 100644 index 0000000000..08d2a3781a --- /dev/null +++ b/app/Services/Analytics/Collectors/Publications/VkPublicationCollector.php @@ -0,0 +1,151 @@ +meta, 'community_token'))) { + // VK refuses every wall read with a community token (error 27), + // so the wall's history can only be imported once the channel is + // reconnected with a user token. Don't spend the request. + return new PublicationPage( + publications: [], + nextCursor: null, + providerExhausted: true, + partialReason: self::PARTIAL_REASON, + ); + } + + $ownerId = VkAnalyticsResponse::ownerId($account); + $offset = max(0, (int) $cursor); + + $data = VkAnalyticsResponse::data($this->get($account, VkApi::endpoint('wall.get'), [ + 'owner_id' => $ownerId, + 'filter' => 'owner', + 'count' => self::PAGE_SIZE, + 'offset' => $offset > 0 ? $offset : null, + 'v' => VkApi::version(), + ]), 'publication history collection'); + + $rows = (array) data_get($data, 'items', []); + $publications = []; + $crossedCutoff = false; + + foreach ($rows as $row) { + $publishedAt = $this->publishedAt(data_get($row, 'date'), timestamp: true); + + if (! $publishedAt) { + continue; + } + + if ($publishedAt->lessThan($cutoff)) { + // A pinned post floats above the date order; skip it without + // mistaking it for the end of the window. + if ((int) data_get($row, 'is_pinned', 0) === 1) { + continue; + } + + $crossedCutoff = true; + + break; + } + + if (filled(data_get($row, 'copy_history'))) { + // A repost of someone else's record, not the wall's own content. + continue; + } + + $attachments = (array) data_get($row, 'attachments', []); + $thumbnail = $this->thumbnail($attachments); + $postId = (string) data_get($row, 'id'); + + $publications[] = new DiscoveredPublication( + providerPostId: $postId, + publishedAt: $publishedAt, + contentType: $this->contentType($attachments), + providerContentType: data_get($attachments, '0.type') ?: (string) data_get($row, 'post_type', 'post'), + permalink: "https://vk.com/wall{$ownerId}_{$postId}", + excerpt: trim((string) data_get($row, 'text', '')), + previewMetadata: filled($thumbnail) ? ['thumbnail_url' => $thumbnail] : null, + providerMetadata: [ + 'views_count' => (int) data_get($row, 'views.count', 0), + 'likes_count' => (int) data_get($row, 'likes.count', 0), + 'reposts_count' => (int) data_get($row, 'reposts.count', 0), + 'comments_count' => (int) data_get($row, 'comments.count', 0), + 'is_pinned' => (int) data_get($row, 'is_pinned', 0) === 1, + ], + ); + } + + $nextOffset = $offset + count($rows); + $nextCursor = ! $crossedCutoff + && count($rows) === self::PAGE_SIZE + && $nextOffset < (int) data_get($data, 'count', 0) + ? (string) $nextOffset + : null; + + return new PublicationPage( + publications: $publications, + nextCursor: $nextCursor, + providerExhausted: $crossedCutoff || $nextCursor === null, + ); + } + + /** @param array $attachments */ + private function contentType(array $attachments): PublicationContentType + { + $types = collect($attachments)->pluck('type'); + + return match (true) { + $types->contains('video') => PublicationContentType::Video, + count($attachments) > 1 => PublicationContentType::Carousel, + $types->contains('photo') => PublicationContentType::Image, + $types->contains('poll') => PublicationContentType::Poll, + $types->contains('link') => PublicationContentType::Link, + default => PublicationContentType::Text, + }; + } + + /** @param array $attachments */ + private function thumbnail(array $attachments): ?string + { + foreach ($attachments as $attachment) { + $sizes = match (data_get($attachment, 'type')) { + 'photo' => (array) data_get($attachment, 'photo.sizes', []), + 'video' => (array) data_get($attachment, 'video.image', []), + default => [], + }; + + if ($sizes === []) { + continue; + } + + // Sizes come smallest first; the `x` photo size (~600px) reads + // well in a card, otherwise take the largest available. + $preferred = collect($sizes)->firstWhere('type', 'x') ?? end($sizes); + $url = data_get($preferred, 'url'); + + if (is_string($url) && $url !== '') { + return $url; + } + } + + return null; + } +} diff --git a/app/Services/Social/Vk/VkApi.php b/app/Services/Social/Vk/VkApi.php index 6d5b9602e3..e4ef73011b 100644 --- a/app/Services/Social/Vk/VkApi.php +++ b/app/Services/Social/Vk/VkApi.php @@ -21,6 +21,16 @@ public static function endpoint(string $method): string return "{$base}/{$method}"; } + /** + * The API version every request must carry as its `v` parameter. Callers + * that authenticate through the Authorization header (the analytics + * collectors) send this alone, keeping the token out of the query string. + */ + public static function version(): string + { + return (string) config('trypost.platforms.vk.api_version'); + } + /** * Parameters every request must carry: the access token and API version. * diff --git a/app/Support/Analytics/VkAnalyticsResponse.php b/app/Support/Analytics/VkAnalyticsResponse.php new file mode 100644 index 0000000000..54aa95abbb --- /dev/null +++ b/app/Support/Analytics/VkAnalyticsResponse.php @@ -0,0 +1,59 @@ + + * + * @throws AnalyticsCollectionException + */ + public static function data(Response $response, string $operation): array + { + $error = $response->json('error'); + + if ($error === null) { + return (array) $response->json('response'); + } + + $code = (int) data_get($error, 'error_code'); + $message = (string) data_get($error, 'error_msg', 'unknown error'); + + $category = match (true) { + $code === 5 => 'authentication', + in_array($code, [6, 9, 29], true) => 'rate_limited', + in_array($code, [7, 15, 27, 30, 200, 203], true) => 'permission', + in_array($code, [1, 10], true) => 'transient', + default => 'malformed', + }; + + throw new AnalyticsCollectionException( + $category, + "{$operation} failed with VK error {$code}: {$message}", + // 18 = page deleted or banned: the wall is gone for good. + gone: $code === 18, + ); + } + + /** + * The wall to read: negative id for a community, positive for a profile. + * Stored at connect time; platform_user_id keeps the same value as a + * fallback for rows created before meta existed (same rule as VkPublisher). + */ + public static function ownerId(SocialAccount $account): int + { + return (int) (data_get($account->meta, 'owner_id') ?? $account->platform_user_id); + } +} diff --git a/lang/ar/accounts.php b/lang/ar/accounts.php index 71a2ff315c..aa4b3b6159 100644 --- a/lang/ar/accounts.php +++ b/lang/ar/accounts.php @@ -196,7 +196,7 @@ 'description' => 'انشر في مجتمع أو على حائطك الشخصي', 'access_token' => 'رمز الوصول', 'access_token_placeholder' => 'vk1.a.…', - 'access_token_hint' => 'موصى به: مفتاح وصول المجتمع (المجتمع ← الإدارة ← استخدام API ← مفاتيح الوصول؛ امنح الصور والحائط وإدارة المجتمع) — يعمل النشر مع أي نوع تطبيق. لا يسمح VK برفع الفيديو بمفاتيح المجتمع. يعمل أيضًا مفتاح المستخدم بصلاحيات wall, photos, groups, video, offline، لكن VK يسمح بالنشر على الحائط لتطبيقات standalone فقط.', + '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 5f8c37cbfe..3be04fd6ef 100644 --- a/lang/de/accounts.php +++ b/lang/de/accounts.php @@ -198,7 +198,7 @@ 'description' => 'In einer Community oder auf deiner Pinnwand veröffentlichen', 'access_token' => 'Zugriffstoken', 'access_token_placeholder' => 'vk1.a.…', - '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.', + '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 und Beitragsstatistiken 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 29dd077b8b..8eac30ab24 100644 --- a/lang/el/accounts.php +++ b/lang/el/accounts.php @@ -196,7 +196,7 @@ 'description' => 'Δημοσιεύστε σε κοινότητα ή στον τοίχο σας', 'access_token' => 'Διακριτικό πρόσβασης', 'access_token_placeholder' => 'vk1.a.…', - 'access_token_hint' => 'Συνιστάται: ένα διακριτικό πρόσβασης κοινότητας (κοινότητα → Διαχείριση → Χρήση API → Διακριτικά πρόσβασης, με δικαιώματα φωτογραφιών, τοίχου και διαχείρισης κοινότητας) — η δημοσίευση λειτουργεί με κάθε τύπο εφαρμογής. Το VK δεν επιτρέπει μεταφόρτωση βίντεο με διακριτικά κοινότητας. Λειτουργεί και διακριτικό χρήστη με δικαιώματα wall, photos, groups, video, offline, αλλά το VK επιτρέπει δημοσίευση στον τοίχο μόνο σε standalone εφαρμογές.', + '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 205d174fd4..747fb76a37 100644 --- a/lang/en/accounts.php +++ b/lang/en/accounts.php @@ -196,7 +196,7 @@ 'description' => 'Publish to a community or your profile wall', 'access_token' => 'Access token', 'access_token_placeholder' => 'vk1.a.…', - '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.', + '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 or post analytics 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 8e1063a1c8..336ed840d5 100644 --- a/lang/es/accounts.php +++ b/lang/es/accounts.php @@ -196,7 +196,7 @@ 'description' => 'Publica en una comunidad o en tu propio muro', 'access_token' => 'Token de acceso', 'access_token_placeholder' => 'vk1.a.…', - '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.', + '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 ni leer estadísticas de publicaciones 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 5b978213c7..471e7b88f7 100644 --- a/lang/fr/accounts.php +++ b/lang/fr/accounts.php @@ -196,7 +196,7 @@ 'description' => 'Publier dans une communauté ou sur votre mur', 'access_token' => 'Jeton d\'accès', 'access_token_placeholder' => 'vk1.a.…', - '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.', + '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 ni l\'envoi de vidéos ni les statistiques de publications 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 5cc44b8539..d919669f99 100644 --- a/lang/it/accounts.php +++ b/lang/it/accounts.php @@ -196,7 +196,7 @@ 'description' => 'Pubblica in una community o sulla tua bacheca', 'access_token' => 'Token di accesso', 'access_token_placeholder' => 'vk1.a.…', - '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.', + '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 né le statistiche dei post 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 35a01c9276..ac4448593a 100644 --- a/lang/ja/accounts.php +++ b/lang/ja/accounts.php @@ -196,7 +196,7 @@ 'description' => 'コミュニティまたは自分のウォールに投稿します', 'access_token' => 'アクセストークン', 'access_token_placeholder' => 'vk1.a.…', - 'access_token_hint' => '推奨: コミュニティアクセストークン(コミュニティ → 管理 → API の利用 → アクセストークン。写真・ウォール・コミュニティ管理を許可)— アプリ種別を問わず投稿できます。コミュニティトークンでは VK は動画アップロードを許可していません。wall, photos, groups, video, offline スコープのユーザートークンも使えますが、ウォール投稿は standalone アプリのみ許可されます。', + '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 6f5854faad..d7b630e69a 100644 --- a/lang/ko/accounts.php +++ b/lang/ko/accounts.php @@ -196,7 +196,7 @@ 'description' => '커뮤니티 또는 내 담벼락에 게시합니다', 'access_token' => '액세스 토큰', 'access_token_placeholder' => 'vk1.a.…', - 'access_token_hint' => '권장: 커뮤니티 액세스 토큰(커뮤니티 → 관리 → API 사용 → 액세스 토큰, 사진·담벼락·커뮤니티 관리 권한 부여) — 앱 유형과 무관하게 게시할 수 있습니다. 커뮤니티 토큰으로는 VK가 동영상 업로드를 허용하지 않습니다. wall, photos, groups, video, offline 권한의 사용자 토큰도 동작하지만, 담벼락 게시는 standalone 앱에만 허용됩니다.', + '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 b2edfd6df7..146a24c025 100644 --- a/lang/nl/accounts.php +++ b/lang/nl/accounts.php @@ -196,7 +196,7 @@ 'description' => 'Publiceer in een community of op je eigen prikbord', 'access_token' => 'Toegangstoken', 'access_token_placeholder' => 'vk1.a.…', - '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.', + '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 en berichtstatistieken 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 ee4d9848dd..8a65c4803c 100644 --- a/lang/pl/accounts.php +++ b/lang/pl/accounts.php @@ -196,7 +196,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' => '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.', + '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 kluczem społeczności przesyłać wideo ani odczytywać statystyk postów. 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 0d36df0755..40f460b09f 100644 --- a/lang/pt-BR/accounts.php +++ b/lang/pt-BR/accounts.php @@ -196,7 +196,7 @@ 'description' => 'Publique em uma comunidade ou no seu mural', 'access_token' => 'Token de acesso', 'access_token_placeholder' => 'vk1.a.…', - '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.', + '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 nem estatísticas de posts 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 6edb1a02ad..9bdd0ec35f 100644 --- a/lang/ru/accounts.php +++ b/lang/ru/accounts.php @@ -196,7 +196,7 @@ 'description' => 'Публикация в сообщество или на свою стену', 'access_token' => 'Ключ доступа', 'access_token_placeholder' => 'vk1.a.…', - 'access_token_hint' => 'Рекомендуется ключ доступа сообщества (сообщество → Управление → Работа с API → Ключи доступа; отметьте фотографии, стену и управление сообществом) — публикация работает с любым типом приложения. Загрузку видео VK по ключу сообщества не разрешает. Подойдёт и пользовательский ключ с правами wall, photos, groups, video, offline, но постить на стену VK разрешает только standalone-приложениям.', + '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 168468054a..eb6d6557e6 100644 --- a/lang/tr/accounts.php +++ b/lang/tr/accounts.php @@ -198,7 +198,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' => 'Ö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.', + '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 ve gönderi istatistiklerine 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 74274a245c..c926516a40 100644 --- a/lang/uk/accounts.php +++ b/lang/uk/accounts.php @@ -196,7 +196,7 @@ 'description' => 'Публікація у спільноту або на власну стіну', 'access_token' => 'Ключ доступу', 'access_token_placeholder' => 'vk1.a.…', - 'access_token_hint' => 'Рекомендовано ключ доступу спільноти (спільнота → Керування → Робота з API → Ключі доступу; позначте фотографії, стіну та керування спільнотою) — публікація працює з будь-яким типом застосунку. Завантаження відео за ключем спільноти VK не дозволяє. Підійде і користувацький ключ із правами wall, photos, groups, video, offline, але постити на стіну VK дозволяє лише standalone-застосункам.', + '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 3c62c56d3c..abd1041baa 100644 --- a/lang/zh/accounts.php +++ b/lang/zh/accounts.php @@ -196,7 +196,7 @@ 'description' => '发布到社群或您自己的动态墙', 'access_token' => '访问令牌', 'access_token_placeholder' => 'vk1.a.…', - 'access_token_hint' => '推荐使用社区访问令牌(社区 → 管理 → API 使用 → 访问令牌;授予照片、留言墙和社区管理权限)—— 任何应用类型都可发布。VK 不允许使用社区令牌上传视频。带有 wall, photos, groups, video, offline 权限的用户令牌也可以,但 VK 仅允许 standalone 应用发布到留言墙。', + 'access_token_hint' => '推荐使用社区访问令牌(社区 → 管理 → API 使用 → 访问令牌;授予照片、留言墙和社区管理权限)—— 任何应用类型都可发布。VK 不允许使用社区令牌上传视频或读取帖子统计数据。带有 wall, photos, groups, video, offline 权限的用户令牌也可以,但 VK 仅允许 standalone 应用发布到留言墙。', 'pick_target' => '选择发布位置', 'target_group' => '社群', 'target_profile' => '个人主页', diff --git a/tests/Feature/Analytics/Collectors/FollowerCollectorsTest.php b/tests/Feature/Analytics/Collectors/FollowerCollectorsTest.php index cd8620d7df..3a52af71ed 100644 --- a/tests/Feature/Analytics/Collectors/FollowerCollectorsTest.php +++ b/tests/Feature/Analytics/Collectors/FollowerCollectorsTest.php @@ -161,3 +161,40 @@ Http::assertSent(fn (Request $request): bool => str_contains($request->url(), '/channels') && $request->hasHeader('Authorization', 'Bearer fresh-token')); }); + +test('vk reads community members through groups.getById', function () { + Http::fake(['*' => Http::response(['response' => ['groups' => [[ + 'id' => 123456, 'members_count' => 321, + ]]]])]); + $account = SocialAccount::factory()->vk()->create(); + + $observation = app(FollowerCollectorFactory::class) + ->for(Platform::Vk) + ->collect($account, CarbonImmutable::parse('2026-09-23', 'UTC')); + + expect($observation->followers)->toBe(321); + Http::assertSent(fn (Request $request): bool => str_contains($request->url(), '/groups.getById') + && $request['group_ids'] == 123456 + && $request['fields'] === 'members_count' + && ! isset($request['access_token']) + && $request->hasHeader('Authorization')); +}); + +test('vk reads profile followers through users.get', function () { + Http::fake(['*' => Http::response(['response' => [[ + 'id' => 111, 'followers_count' => 55, + ]]])]); + $account = SocialAccount::factory()->vk()->create([ + 'platform_user_id' => '111', + 'meta' => ['owner_id' => 111, 'is_group' => false, 'vk_user_id' => 111], + ]); + + $observation = app(FollowerCollectorFactory::class) + ->for(Platform::Vk) + ->collect($account, CarbonImmutable::parse('2026-09-23', 'UTC')); + + expect($observation->followers)->toBe(55); + Http::assertSent(fn (Request $request): bool => str_contains($request->url(), '/users.get') + && $request['user_ids'] == 111 + && $request['fields'] === 'followers_count'); +}); diff --git a/tests/Feature/Analytics/Collectors/OpenPublicationCollectorsTest.php b/tests/Feature/Analytics/Collectors/OpenPublicationCollectorsTest.php index c6879d85f8..a85ba05456 100644 --- a/tests/Feature/Analytics/Collectors/OpenPublicationCollectorsTest.php +++ b/tests/Feature/Analytics/Collectors/OpenPublicationCollectorsTest.php @@ -8,6 +8,7 @@ use App\Services\Analytics\Collectors\Publications\BlueskyPublicationCollector; use App\Services\Analytics\Collectors\Publications\MastodonPublicationCollector; use App\Services\Analytics\Collectors\Publications\PublicationHistoryCollectorFactory; +use App\Services\Analytics\Collectors\Publications\VkPublicationCollector; use Carbon\CarbonImmutable; use Illuminate\Http\Client\Request; use Illuminate\Support\Facades\Bus; @@ -313,3 +314,93 @@ ->and($page->nextCursor)->toBeNull() ->and($page->providerExhausted)->toBeTrue(); }); + +test('vk pages the wall by offset, skips reposts and keeps a pinned post out of the cutoff decision', function () { + $now = CarbonImmutable::parse('2026-09-25 12:00:00', 'UTC'); + Http::fake(['*' => Http::response(['response' => [ + 'count' => 250, + 'items' => [ + [ + // Pinned and ancient: floats above the date order, must not end the window. + 'id' => 1, 'date' => $now->subYears(2)->unix(), 'is_pinned' => 1, + 'text' => 'Pinned', 'post_type' => 'post', + ], + [ + 'id' => 90, 'date' => $now->subDay()->unix(), 'text' => 'Repost', + 'post_type' => 'post', 'copy_history' => [['id' => 5]], + ], + [ + 'id' => 89, 'date' => $now->subDays(2)->unix(), 'text' => 'Own photo post', + 'post_type' => 'post', + 'attachments' => [['type' => 'photo', 'photo' => ['sizes' => [ + ['type' => 'm', 'url' => 'https://img.example/m.jpg'], + ['type' => 'x', 'url' => 'https://img.example/x.jpg'], + ]]]], + 'views' => ['count' => 10], 'likes' => ['count' => 2], + 'reposts' => ['count' => 1], 'comments' => ['count' => 0], + ], + [ + 'id' => 70, 'date' => $now->subYears(3)->unix(), 'text' => 'Beyond the window', + 'post_type' => 'post', + ], + ], + ]])]); + $account = SocialAccount::factory()->vk()->create(); + + $page = app(VkPublicationCollector::class)->page( + $account, + null, + $now->subYear(), + ); + + expect($page->publications)->toHaveCount(1) + ->and($page->publications[0]->providerPostId)->toBe('89') + ->and($page->publications[0]->contentType)->toBe(PublicationContentType::Image) + ->and($page->publications[0]->permalink)->toBe('https://vk.com/wall-123456_89') + ->and($page->publications[0]->previewMetadata)->toBe(['thumbnail_url' => 'https://img.example/x.jpg']) + ->and($page->publications[0]->providerMetadata)->toMatchArray([ + 'views_count' => 10, 'likes_count' => 2, 'reposts_count' => 1, 'comments_count' => 0, + ]) + ->and($page->nextCursor)->toBeNull() + ->and($page->providerExhausted)->toBeTrue(); + Http::assertSent(fn (Request $request): bool => str_contains($request->url(), '/wall.get') + && $request['owner_id'] == -123456 + && $request['filter'] === 'owner' + && ! isset($request['offset'])); +}); + +test('vk hands the next offset as the cursor while the window stays open', function () { + $now = CarbonImmutable::parse('2026-09-25 12:00:00', 'UTC'); + $items = []; + for ($i = 0; $i < 100; $i++) { + $items[] = ['id' => 1000 - $i, 'date' => $now->subHours($i + 1)->unix(), 'text' => "Post {$i}", 'post_type' => 'post']; + } + Http::fake(['*' => Http::response(['response' => ['count' => 250, 'items' => $items]])]); + $account = SocialAccount::factory()->vk()->create(); + + $page = app(VkPublicationCollector::class)->page($account, '100', $now->subYear()); + + expect($page->publications)->toHaveCount(100) + ->and($page->nextCursor)->toBe('200') + ->and($page->providerExhausted)->toBeFalse(); + Http::assertSent(fn (Request $request): bool => str_contains($request->url(), '/wall.get') + && $request['offset'] == 100); +}); + +test('vk skips history for a community token without spending a request', function () { + Http::fake(); + $account = SocialAccount::factory()->vk()->create([ + 'meta' => ['owner_id' => -123456, 'is_group' => true, 'community_token' => true], + ]); + + $page = app(VkPublicationCollector::class)->page( + $account, + null, + CarbonImmutable::parse('2026-01-01', 'UTC'), + ); + + expect($page->publications)->toBe([]) + ->and($page->providerExhausted)->toBeTrue() + ->and($page->partialReason)->toBe('vk_reconnect_with_user_token_for_history'); + Http::assertNothingSent(); +}); diff --git a/tests/Feature/Analytics/Collectors/PublicationMetricsCollectorsTest.php b/tests/Feature/Analytics/Collectors/PublicationMetricsCollectorsTest.php index 0960c0ca77..26a07bf8f1 100644 --- a/tests/Feature/Analytics/Collectors/PublicationMetricsCollectorsTest.php +++ b/tests/Feature/Analytics/Collectors/PublicationMetricsCollectorsTest.php @@ -20,6 +20,7 @@ use App\Services\Analytics\Collectors\Metrics\PinterestPublicationMetricsCollector; use App\Services\Analytics\Collectors\Metrics\PublicationMetricsCollectorFactory; use App\Services\Analytics\Collectors\Metrics\ThreadsPublicationMetricsCollector; +use App\Services\Analytics\Collectors\Metrics\VkPublicationMetricsCollector; use App\Services\Analytics\Collectors\Metrics\TikTokPublicationMetricsCollector; use App\Services\Analytics\Collectors\Metrics\XPublicationMetricsCollector; use App\Services\Analytics\Collectors\Metrics\YouTubePublicationMetricsCollector; @@ -42,6 +43,7 @@ [Platform::TikTok, TikTokPublicationMetricsCollector::class], [Platform::Bluesky, BlueskyPublicationMetricsCollector::class], [Platform::Mastodon, MastodonPublicationMetricsCollector::class], + [Platform::Vk, VkPublicationMetricsCollector::class], ]); test('bluesky normalizes measured zero and does not fabricate omitted counts', function () { @@ -128,6 +130,10 @@ 'mastodon' => [Platform::Mastodon, [ 'favourites_count' => 0, 'replies_count' => 2, 'reblogs_count' => 1, ], ['reactions' => 0, 'comments' => 2, 'shares' => 1, 'engagements' => 3]], + 'vk' => [Platform::Vk, ['response' => ['items' => [[ + 'views' => ['count' => 50], 'likes' => ['count' => 0], + 'comments' => ['count' => 2], 'reposts' => ['count' => 1], + ]]]], ['views' => 50, 'reactions' => 0, 'comments' => 2, 'shares' => 1, 'engagements' => 3]], ]); test('pinterest preserves its rolling window basis', function () { @@ -926,3 +932,92 @@ function instagramInsightsFake(array $values, array $rejected = []): void expect(fn () => app(TikTokPublicationMetricsCollector::class)->collect($publication, CarbonImmutable::today('UTC'))) ->toThrow(fn (AnalyticsCollectionException $exception): bool => $exception->category === 'authentication'); }); + +test('vk maps its 200-with-error body onto the analytics categories', function () { + Http::fake(['*' => Http::response(['error' => [ + 'error_code' => 5, + 'error_msg' => 'User authorization failed: invalid access_token.', + ]])]); + $account = SocialAccount::factory()->vk()->create(); + $publication = AnalyticsPublication::factory()->create([ + 'workspace_id' => $account->workspace_id, + 'social_account_id' => $account->id, + 'platform' => Platform::Vk, + 'platform_user_id' => $account->platform_user_id, + 'remote_id' => '42', + ]); + + try { + app(VkPublicationMetricsCollector::class)->collect($publication, CarbonImmutable::parse('2026-09-23', 'UTC')); + $this->fail('Expected AnalyticsCollectionException'); + } catch (AnalyticsCollectionException $exception) { + expect($exception->category)->toBe('authentication'); + } +}); + +test('vk reports a deleted wall post as gone instead of failing the sweep', function () { + Http::fake(['*' => Http::response(['response' => ['count' => 0, 'items' => []]])]); + $account = SocialAccount::factory()->vk()->create(); + $publication = AnalyticsPublication::factory()->create([ + 'workspace_id' => $account->workspace_id, + 'social_account_id' => $account->id, + 'platform' => Platform::Vk, + 'platform_user_id' => $account->platform_user_id, + 'remote_id' => '404404', + ]); + + try { + app(VkPublicationMetricsCollector::class)->collect($publication, CarbonImmutable::parse('2026-09-23', 'UTC')); + $this->fail('Expected AnalyticsCollectionException'); + } catch (AnalyticsCollectionException $exception) { + expect($exception->gone)->toBeTrue(); + } +}); + +test('vk reads the post of the account wall by owner-qualified id', function () { + Http::fake(['*' => Http::response(['response' => ['items' => [[ + 'views' => ['count' => 7], 'likes' => ['count' => 1], + 'comments' => ['count' => 0], 'reposts' => ['count' => 0], + ]]]])]); + $account = SocialAccount::factory()->vk()->create(); + $publication = AnalyticsPublication::factory()->create([ + 'workspace_id' => $account->workspace_id, + 'social_account_id' => $account->id, + 'platform' => Platform::Vk, + 'platform_user_id' => $account->platform_user_id, + 'remote_id' => '42', + ]); + + app(VkPublicationMetricsCollector::class)->collect($publication, CarbonImmutable::parse('2026-09-23', 'UTC')); + + Http::assertSent(fn (Request $request): bool => str_contains($request->url(), '/wall.getById') + && $request['posts'] === '-123456_42' + && $request['v'] === config('trypost.platforms.vk.api_version') + && ! isset($request['access_token']) + && $request->hasHeader('Authorization')); +}); + +test('vk refuses post metrics for a community token before any request', function () { + Http::fake(); + $account = SocialAccount::factory()->vk()->create([ + 'meta' => ['owner_id' => -123456, 'is_group' => true, 'community_token' => true], + ]); + $publication = AnalyticsPublication::factory()->create([ + 'workspace_id' => $account->workspace_id, + 'social_account_id' => $account->id, + 'platform' => Platform::Vk, + 'platform_user_id' => $account->platform_user_id, + 'remote_id' => '42', + ]); + + try { + app(VkPublicationMetricsCollector::class)->collect($publication, CarbonImmutable::parse('2026-09-23', 'UTC')); + $this->fail('Expected AnalyticsCollectionException'); + } catch (AnalyticsCollectionException $exception) { + expect($exception->category)->toBe('permission'); + } + + // Connecting the account bootstraps analytics inline, so assert only + // that the refusal itself never reached the wall. + Http::assertNotSent(fn (Request $request): bool => str_contains($request->url(), '/wall.getById')); +});