diff --git a/app/Enums/PostPlatform/ContentType.php b/app/Enums/PostPlatform/ContentType.php index e68193aad..fcdbf47d6 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 20e3ff754..4405c7bd7 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 { @@ -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 000000000..ee86ca74c --- /dev/null +++ b/app/Exceptions/Social/VkPublishException.php @@ -0,0 +1,60 @@ +body(); + $error = $response->json('error'); + + // VK reports failures as HTTP 200 with an `error` object; transport + // failures (5xx) have no such object. + $code = (int) data_get($error, 'error_code', 0); + $message = (string) data_get($error, 'error_msg', 'An unknown VK error occurred.'); + + if (self::isConfirmedDeadToken($response)) { + throw new TokenExpiredException( + message: $message, + platformErrorCode: (string) $code, + ); + } + + return new static( + userMessage: $message, + category: match (true) { + in_array($code, [6, 9, 29], true) => ErrorCategory::RateLimit, + in_array($code, [7, 15, 200, 214, 219], true) => ErrorCategory::Permission, + in_array($code, [100, 118, 129], true) => ErrorCategory::MediaFormat, + $code === 0 && $response->serverError() => ErrorCategory::ServerError, + default => ErrorCategory::Unknown, + }, + platformErrorCode: $code > 0 ? (string) $code : (string) $response->status(), + rawResponse: $rawResponse, + ); + } + + public function platform(): string + { + return 'vk'; + } + + /** + * Whether this response confirms the account's own access_token is dead. + * VK error 5 is "User authorization failed" — the token was revoked or + * invalidated (password change, security logout). Shared with + * ConnectionVerifier so publish and verify agree on what a dead token + * looks like. + */ + public static function isConfirmedDeadToken(Response $response): bool + { + return (int) $response->json('error.error_code') === 5; + } +} diff --git a/app/Http/Controllers/Auth/SocialConnectionController.php b/app/Http/Controllers/Auth/SocialConnectionController.php index c0ee85396..754981622 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 000000000..dd6186d64 --- /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 90110b27b..bc7f844b5 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 e74c1edfe..00798cc5a 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/Analytics/Collectors/Followers/FollowerCollectorFactory.php b/app/Services/Analytics/Collectors/Followers/FollowerCollectorFactory.php index 2717c3156..ee308c179 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 000000000..2c6a6d009 --- /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 2953f833c..3e22f712c 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 000000000..29aa42693 --- /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 ef6c597c5..7aa937095 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 000000000..08d2a3781 --- /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/Media/MediaOptimizer.php b/app/Services/Media/MediaOptimizer.php index 15c899800..4f744d5b4 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 54ed6ca77..9d3391634 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 000000000..e4ef73011 --- /dev/null +++ b/app/Services/Social/Vk/VkApi.php @@ -0,0 +1,46 @@ + $accessToken, + 'v' => (string) config('trypost.platforms.vk.api_version'), + ]; + } +} diff --git a/app/Services/Social/VkPublisher.php b/app/Services/Social/VkPublisher.php new file mode 100644 index 000000000..2675457c5 --- /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/app/Support/Analytics/VkAnalyticsResponse.php b/app/Support/Analytics/VkAnalyticsResponse.php new file mode 100644 index 000000000..54aa95abb --- /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/config/trypost.php b/config/trypost.php index a682d06aa..f61d2154a 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 61e6ed4e1..e6b42957f 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 0f56bc9e3..d9f120e85 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 39eb84706..aa4b3b615 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 b2f7a9ef3..abdc049a0 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 359071350..1ac0beab5 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 cc84b3342..3be04fd6e 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 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', + '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 fc05258d2..8e5bf369b 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 34fdd6f91..4f7d01d64 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 4957225ba..8eac30ab2 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 6e1afcee4..8a6082798 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 40a5fd543..8948bebf8 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 45181f1a8..747fb76a3 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 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', + '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 26fe5bcfc..ac1abb672 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 d469dee19..a2bf836bc 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 b18daf7a9..336ed840d 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 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', + '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 4f93c9599..c1ce7d510 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 a699ac95f..21c77db80 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 dd7d493d7..471e7b88f 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 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', + '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 17730a401..13fee123f 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 721c6040c..4616867ef 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 829199875..d919669f9 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 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', + '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 ffd9b6c35..55d0583af 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 4a48423f9..7f9f5c473 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 2c9abb051..ac4448593 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 2bd6c1d80..cc9bb1744 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 5b76c7947..8aa383c0f 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 609f9d0d6..d7b630e69 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 40fc52a84..34f4ef935 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 bc3b61238..8442635a8 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 be7861198..146a24c02 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 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', + '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 a060e475b..2152537dc 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 6616a1edd..be166e4c4 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 a4ff4125b..8a65c4803 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 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', + '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 630a7abcf..9cc990062 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 24374ca05..5e9f1985c 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 3f6f7d332..40f460b09 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 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', + '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 0bad0aa1d..8ce6c948f 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 7d6856cab..15b7b6d8f 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 ceaf3f000..9bdd0ec35 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 f8d317cfe..bd68cd488 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 7c0d0b927..02535f312 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 9d82088de..eb6d6557e 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 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', + '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 54deb794e..aa29718c9 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 d45bd2a3a..10b3226be 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 0fce3c21d..c926516a4 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 1eca911f7..53103b25f 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 4783f3423..ed76cfcff 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 90ca4705a..abd1041ba 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 4a213956f..5a633d08f 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 4de091b98..7f329ffc0 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 000000000..3ad605a5d Binary files /dev/null and b/public/images/accounts/vk.png differ diff --git a/resources/js/components/posts/previews/PlatformPreview.vue b/resources/js/components/posts/previews/PlatformPreview.vue index 7956162c1..978090983 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 000000000..2c50171de --- /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 c5a51c62b..afed7c30f 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 39a059176..a69f89fbf 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 000000000..766bfd40e --- /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 274947504..f64ee6175 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 d0744d1a4..67e4031b5 100644 --- a/resources/js/types/platform.ts +++ b/resources/js/types/platform.ts @@ -14,6 +14,7 @@ export const Platform = { Telegram: 'telegram', Discord: 'discord', GoogleBusiness: 'google_business', + Vk: 'vk', } as const; export type PlatformValue = (typeof Platform)[keyof typeof Platform]; diff --git a/resources/views/prompts/post_content/_platform_style.blade.php b/resources/views/prompts/post_content/_platform_style.blade.php index bf04b3f56..f8ebcd724 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 d9083f955..a90272058 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/Analytics/Collectors/FollowerCollectorsTest.php b/tests/Feature/Analytics/Collectors/FollowerCollectorsTest.php index cd8620d7d..3a52af71e 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 c6879d85f..a85ba0545 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 0960c0ca7..26a07bf8f 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')); +}); diff --git a/tests/Feature/Services/Social/ConnectionVerifierTest.php b/tests/Feature/Services/Social/ConnectionVerifierTest.php index 6ec1f3aa7..52ea4e84d 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 000000000..f89cae6a1 --- /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 000000000..c96433e75 --- /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 8d07b1f78..3dc6f6912 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 6f89da0fc..20296ba0e 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 000000000..5a2c9eeab --- /dev/null +++ b/tests/Unit/Exceptions/Social/VkPublishExceptionTest.php @@ -0,0 +1,50 @@ + ['error_code' => $code, 'error_msg' => $msg]], $status); + + return Http::fake(['*' => $response])->post('https://vk.example/method/wall.post'); +} + +test('error 5 (authorization failed) throws TokenExpiredException', function () { + VkPublishException::fromApiResponse(fakeVkErrorResponse(5, 'User authorization failed.')); +})->throws(TokenExpiredException::class); + +test('error 6 (too many requests) maps to RateLimit category', function () { + $exception = VkPublishException::fromApiResponse(fakeVkErrorResponse(6, 'Too many requests per second.')); + + expect($exception->category)->toBe(ErrorCategory::RateLimit) + ->and($exception->platformErrorCode)->toBe('6'); +}); + +test('error 214 (post access denied) maps to Permission category', function () { + $exception = VkPublishException::fromApiResponse(fakeVkErrorResponse(214, 'Access to adding post denied.')); + + expect($exception->category)->toBe(ErrorCategory::Permission) + ->and($exception->userMessage)->toBe('Access to adding post denied.'); +}); + +test('unknown error code maps to Unknown category with vk platform', function () { + $exception = VkPublishException::fromApiResponse(fakeVkErrorResponse(1, 'Unknown error occurred.')); + + expect($exception->category)->toBe(ErrorCategory::Unknown) + ->and($exception->platform())->toBe('vk'); +}); + +test('transport 5xx without vk error object maps to ServerError', function () { + $response = Http::response('Bad gateway', 502); + $fakeResponse = Http::fake(['*' => $response])->post('https://vk.example/method/wall.post'); + + $exception = VkPublishException::fromApiResponse($fakeResponse); + + expect($exception->category)->toBe(ErrorCategory::ServerError) + ->and($exception->platformErrorCode)->toBe('502'); +});