diff --git a/app/Enums/PostPlatform/ContentType.php b/app/Enums/PostPlatform/ContentType.php index eba15e5d1..fdff9ed9d 100644 --- a/app/Enums/PostPlatform/ContentType.php +++ b/app/Enums/PostPlatform/ContentType.php @@ -58,6 +58,8 @@ enum ContentType: string // Google Business Profile case GoogleBusinessPost = 'google_business_post'; + // VK + case VkPost = 'vk_post'; /** * AI generation format for an Instagram carousel. Not a content type — @@ -88,6 +90,7 @@ public function label(): string self::TelegramPost => 'Post', self::DiscordMessage => 'Message', self::GoogleBusinessPost => 'Post', + self::VkPost => 'Post', }; } @@ -113,6 +116,7 @@ public function platform(): SocialPlatform self::TelegramPost => SocialPlatform::Telegram, self::DiscordMessage => SocialPlatform::Discord, self::GoogleBusinessPost => SocialPlatform::GoogleBusiness, + self::VkPost => SocialPlatform::Vk, }; } @@ -183,6 +187,7 @@ public function maxMediaCount(): int self::TelegramPost => 10, self::DiscordMessage => 10, self::GoogleBusinessPost => 1, + self::VkPost => 10, }; } @@ -497,6 +502,7 @@ public function supportsVideo(): bool self::TelegramPost => true, self::DiscordMessage => true, self::GoogleBusinessPost => false, + self::VkPost => true, }; } @@ -650,6 +656,7 @@ public static function defaultFor(SocialPlatform $platform): self SocialPlatform::Telegram => self::TelegramPost, SocialPlatform::Discord => self::DiscordMessage, SocialPlatform::GoogleBusiness => self::GoogleBusinessPost, + SocialPlatform::Vk => self::VkPost, }; } } diff --git a/app/Enums/SocialAccount/Platform.php b/app/Enums/SocialAccount/Platform.php index c87258188..996c7500c 100644 --- a/app/Enums/SocialAccount/Platform.php +++ b/app/Enums/SocialAccount/Platform.php @@ -24,6 +24,7 @@ enum Platform: string case Telegram = 'telegram'; case Discord = 'discord'; case GoogleBusiness = 'google_business'; + case Vk = 'vk'; public function network(): string { @@ -63,6 +64,7 @@ public function label(): string self::Telegram => 'Telegram', self::Discord => 'Discord', self::GoogleBusiness => 'Google Business Profile', + self::Vk => 'VK', }; } @@ -83,6 +85,7 @@ public function color(): string self::Telegram => '#26A5E4', self::Discord => '#5865F2', self::GoogleBusiness => '#4285F4', + self::Vk => '#0077FF', }; } @@ -102,6 +105,7 @@ public function allowedMediaTypes(): array self::Telegram => [MediaType::Image, MediaType::Video], self::Discord => [MediaType::Image, MediaType::Video], self::GoogleBusiness => [MediaType::Image], + self::Vk => [MediaType::Image, MediaType::Video], }; } @@ -121,6 +125,7 @@ public function maxImages(): int self::Telegram => 10, self::Discord => 10, self::GoogleBusiness => 1, + self::Vk => 10, }; } @@ -145,6 +150,7 @@ public function altTextMaxLength(): ?int self::Pinterest => 500, self::Discord => 1024, self::TikTok, self::YouTube, self::Telegram, self::GoogleBusiness => null, + self::TikTok, self::YouTube, self::Telegram, self::Vk => null, }; } @@ -179,6 +185,7 @@ public function supportsAltText(): bool * - Telegram: 4096 for a text message (media captions are capped at 1024, * handled in the publisher by sending long text as its own message) * - Google Business Profile Local Post `summary`: 1500 + * - VK: 15895 characters for a wall post */ public function maxContentLength(): int { @@ -196,6 +203,7 @@ public function maxContentLength(): int self::Telegram => 4096, self::Discord => 2000, self::GoogleBusiness => 1500, + self::Vk => 15895, }; } @@ -244,6 +252,8 @@ public function recommendedAiContentLength(): int // Google Business Profile — image does most of the work, keep the // summary tight and scannable self::GoogleBusiness => 300, + // VK — feed favors short posts; long reads live in Articles + self::Vk => 400, }; } @@ -268,6 +278,7 @@ public function requiredPublishScopes(): array self::Telegram => [], self::Discord => [], self::GoogleBusiness => ['https://www.googleapis.com/auth/business.manage'], + self::Vk => [], }; } @@ -287,6 +298,7 @@ public function supportsTextOnly(): bool self::Telegram => true, self::Discord => true, self::GoogleBusiness => true, + self::Vk => true, }; } @@ -426,6 +438,7 @@ public function isEnabled(): bool self::Telegram => 'TELEGRAM_ENABLED', self::Discord => 'DISCORD_ENABLED', self::GoogleBusiness => 'GOOGLE_BUSINESS_ENABLED', + self::Vk => 'VK_ENABLED', }, true), ); } diff --git a/app/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/App/AnalyticsController.php b/app/Http/Controllers/App/AnalyticsController.php index a764e2796..b5a406163 100644 --- a/app/Http/Controllers/App/AnalyticsController.php +++ b/app/Http/Controllers/App/AnalyticsController.php @@ -14,6 +14,7 @@ use App\Services\Social\LinkedInPageAnalytics; use App\Services\Social\PinterestAnalytics; use App\Services\Social\Telegram\TelegramAnalytics; +use App\Services\Social\Vk\VkAnalytics; use App\Services\Social\ThreadsAnalytics; use App\Services\Social\TikTokAnalytics; use App\Services\Social\XAnalytics; @@ -40,6 +41,7 @@ class AnalyticsController extends Controller Platform::YouTube, Platform::Telegram, Platform::GoogleBusiness, + Platform::Vk, ]; public function index(Request $request): Response @@ -125,6 +127,7 @@ private function metricsFor(SocialAccount $account, ?Carbon $since, ?Carbon $unt Platform::YouTube => app(YouTubeAnalytics::class)->getMetrics($account, $since, $until), Platform::Telegram => app(TelegramAnalytics::class)->getMetrics($account), Platform::GoogleBusiness => app(GoogleBusinessAnalytics::class)->getMetrics($account, $since, $until), + Platform::Vk => app(VkAnalytics::class)->getMetrics($account), default => [], }; } catch (PlatformUnavailableException|ConnectionException $e) { diff --git a/app/Http/Controllers/Auth/VkController.php b/app/Http/Controllers/Auth/VkController.php new file mode 100644 index 000000000..57d920a93 --- /dev/null +++ b/app/Http/Controllers/Auth/VkController.php @@ -0,0 +1,329 @@ +ensurePlatformEnabled(); + + $workspace = $request->user()->currentWorkspace; + + $this->authorize('manageAccounts', $workspace); + + return Inertia::render('accounts/VkConnect', [ + 'errors' => session('errors')?->getBag('default')?->toArray() ?? [], + ]); + } + + public function store(Request $request): InertiaResponse + { + $this->ensurePlatformEnabled(); + + $request->validate([ + 'access_token' => 'required|string|min:10', + 'owner_id' => 'nullable|integer', + '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', [ + 'errors' => [], + 'communityToken' => true, + ]); + } + + return $this->storeCommunityAccount($request, $workspace); + } + + $targets = $this->buildTargets($request->access_token, $user); + + if (! $request->filled('owner_id')) { + return Inertia::render('accounts/VkConnect', [ + 'errors' => [], + 'targets' => array_values($targets), + ]); + } + + $target = $targets[(int) $request->owner_id] ?? null; + + if ($target === null) { + throw ValidationException::withMessages(['owner_id' => __('accounts.vk.invalid_target')]); + } + + $avatarPath = $target['photo'] ? uploadFromUrl($target['photo']) : null; + + $workspace->socialAccounts()->updateOrCreate( + [ + 'platform' => $this->platform->value, + 'platform_user_id' => (string) $target['owner_id'], + ], + [ + 'username' => $target['screen_name'], + 'display_name' => $target['name'], + 'avatar_url' => $avatarPath, + 'access_token' => $request->access_token, + 'refresh_token' => null, + // vkhost/standalone tokens are issued with the `offline` + // scope and never expire; there is no refresh flow. + 'token_expires_at' => null, + 'status' => Status::Connected, + 'error_message' => null, + 'disconnected_at' => null, + 'meta' => [ + 'owner_id' => $target['owner_id'], + 'is_group' => $target['owner_id'] < 0, + 'vk_user_id' => (int) data_get($user, 'id'), + ], + ], + ); + + return $this->popupCallback(true, __('accounts.popup_callback.connected'), $this->platform->value); + } catch (ValidationException $e) { + throw $e; + } catch (\Exception $e) { + Log::error('VK connection error', [ + 'error' => $e->getMessage(), + ]); + + throw ValidationException::withMessages(['access_token' => __('accounts.vk.connection_error')]); + } + } + + /** + * 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; + } + + /** + * Connect the community a community access token belongs to. VK has no + * API to resolve a community from its token, so the community comes from + * the form; groups.getCallbackConfirmationCode (callable only with the + * community's own token, unlike groups.getOnlineStatus it does not need + * community messages to be enabled) then proves the token belongs to it. + */ + private function storeCommunityAccount(Request $request, Workspace $workspace): InertiaResponse + { + $groups = $this->callVk($request->access_token, 'groups.getById', [ + 'group_ids' => $this->normalizeCommunity((string) $request->community), + 'fields' => 'screen_name,photo_200', + ]); + + // v5.199 отдаёт response.groups[], более старые версии — response[]. + $group = data_get($groups, 'groups.0') ?? data_get($groups, '0'); + + if (! is_array($group)) { + throw ValidationException::withMessages(['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'); + $photo = data_get($group, 'photo_200'); + + $workspace->socialAccounts()->updateOrCreate( + [ + 'platform' => $this->platform->value, + 'platform_user_id' => (string) $ownerId, + ], + [ + 'username' => data_get($group, 'screen_name'), + 'display_name' => (string) data_get($group, 'name'), + 'avatar_url' => $photo ? uploadFromUrl($photo) : null, + 'access_token' => $request->access_token, + 'refresh_token' => null, + // Community access tokens never expire; there is no refresh flow. + 'token_expires_at' => null, + 'status' => Status::Connected, + 'error_message' => null, + 'disconnected_at' => null, + 'meta' => [ + 'owner_id' => $ownerId, + 'is_group' => true, + 'community_token' => true, + ], + ], + ); + + return $this->popupCallback(true, __('accounts.popup_callback.connected'), $this->platform->value); + } + + /** + * Walls the token may publish to: the user's own profile plus communities + * where the user is an administrator or editor. Keyed by owner_id so the + * second form step can only pick something this token really manages. + * + * @param array $user + * @return array + */ + private function buildTargets(string $accessToken, array $user): array + { + $targets = []; + + $userId = (int) data_get($user, 'id'); + $targets[$userId] = [ + 'owner_id' => $userId, + 'name' => trim(data_get($user, 'first_name', '').' '.data_get($user, 'last_name', '')), + 'screen_name' => data_get($user, 'screen_name'), + 'photo' => data_get($user, 'photo_200'), + 'is_group' => false, + ]; + + $groups = $this->callVk($accessToken, 'groups.get', [ + 'filter' => 'admin,editor', + 'extended' => 1, + 'fields' => 'screen_name,photo_200', + 'count' => 200, + ]); + + foreach (data_get($groups, 'items', []) as $group) { + $groupId = (int) data_get($group, 'id'); + $targets[-$groupId] = [ + 'owner_id' => -$groupId, + 'name' => (string) data_get($group, 'name'), + 'screen_name' => data_get($group, 'screen_name'), + 'photo' => data_get($group, 'photo_200'), + 'is_group' => true, + ]; + } + + return $targets; + } + + /** + * Call a VK method and return its `response` payload. VK reports failures + * as HTTP 200 with an `error` object — surfaced here as a validation + * error on the token field so the form shows what VK said. + * + * @return array + */ + private function callVk(string $accessToken, string $method, array $params): array + { + $response = Http::asForm()->post( + VkApi::endpoint($method), + $params + VkApi::baseParams($accessToken), + ); + + $error = $response->json('error'); + + if ($response->failed() || $error !== null) { + Log::error('VK connect API call failed', [ + 'method' => $method, + 'status' => $response->status(), + 'error_code' => data_get($error, 'error_code'), + ]); + + throw ValidationException::withMessages([ + 'access_token' => data_get($error, 'error_msg') ?: __('accounts.vk.connection_error'), + ]); + } + + return (array) $response->json('response'); + } +} diff --git a/app/Jobs/PublishToSocialPlatform.php b/app/Jobs/PublishToSocialPlatform.php index 096463a89..6801f5483 100644 --- a/app/Jobs/PublishToSocialPlatform.php +++ b/app/Jobs/PublishToSocialPlatform.php @@ -29,6 +29,7 @@ use App\Services\Social\Telegram\TelegramPublisher; use App\Services\Social\ThreadsPublisher; use App\Services\Social\TikTokPublisher; +use App\Services\Social\VkPublisher; use App\Services\Social\XPublisher; use App\Services\Social\YouTubePublisher; use App\Support\Social\GoogleBusinessDerivativeCleaner; @@ -416,7 +417,7 @@ private function safeFailureMessage(Throwable $e): string : 'An unexpected error occurred while publishing. Please try again.'; } - private function getPublisher(): LinkedInPublisher|LinkedInPagePublisher|XPublisher|TikTokPublisher|YouTubePublisher|FacebookPublisher|InstagramPublisher|ThreadsPublisher|PinterestPublisher|BlueskyPublisher|MastodonPublisher|TelegramPublisher|DiscordPublisher|GoogleBusinessPublisher + private function getPublisher(): LinkedInPublisher|LinkedInPagePublisher|XPublisher|TikTokPublisher|YouTubePublisher|FacebookPublisher|InstagramPublisher|ThreadsPublisher|PinterestPublisher|BlueskyPublisher|MastodonPublisher|TelegramPublisher|DiscordPublisher|GoogleBusinessPublisher|VkPublisher { return match ($this->postPlatform->platform) { SocialPlatform::LinkedIn => app(LinkedInPublisher::class), @@ -433,6 +434,7 @@ private function getPublisher(): LinkedInPublisher|LinkedInPagePublisher|XPublis SocialPlatform::Telegram => app(TelegramPublisher::class), SocialPlatform::Discord => app(DiscordPublisher::class), SocialPlatform::GoogleBusiness => app(GoogleBusinessPublisher::class), + SocialPlatform::Vk => app(VkPublisher::class), }; } diff --git a/app/Models/SocialAccount.php b/app/Models/SocialAccount.php index 804836ff2..48c71acae 100644 --- a/app/Models/SocialAccount.php +++ b/app/Models/SocialAccount.php @@ -280,6 +280,9 @@ protected function profileUrl(): Attribute : (filled(data_get($this->meta, 'location_id')) ? GoogleBusinessResourceName::dashboardUrl((string) data_get($this->meta, 'location_id')) : null), + SocialPlatform::Vk => $username + ? "https://vk.com/{$username}" + : ($platformUserId ? 'https://vk.com/'.(str_starts_with($platformUserId, '-') ? 'club'.ltrim($platformUserId, '-') : "id{$platformUserId}") : null), default => null, }; }, diff --git a/app/Services/Media/MediaOptimizer.php b/app/Services/Media/MediaOptimizer.php index 35ed77b94..1700bcd87 100644 --- a/app/Services/Media/MediaOptimizer.php +++ b/app/Services/Media/MediaOptimizer.php @@ -360,6 +360,12 @@ private function getImageConfig(Platform $platform): array 'format' => 'image/jpeg', 'quality' => 100, ], + Platform::Vk => [ + 'max_width' => 2560, + 'max_size' => 50 * 1024 * 1024, + 'format' => 'image/jpeg', + 'quality' => 100, + ], }; } } diff --git a/app/Services/Post/PostMetricsFetcher.php b/app/Services/Post/PostMetricsFetcher.php index dec6438a9..2f289f0ac 100644 --- a/app/Services/Post/PostMetricsFetcher.php +++ b/app/Services/Post/PostMetricsFetcher.php @@ -16,6 +16,7 @@ use App\Services\Social\MastodonAnalytics; use App\Services\Social\PinterestAnalytics; use App\Services\Social\Telegram\TelegramAnalytics; +use App\Services\Social\Vk\VkAnalytics; use App\Services\Social\ThreadsAnalytics; use App\Services\Social\TikTokAnalytics; use App\Services\Social\XAnalytics; @@ -73,6 +74,7 @@ public function forPlatform(PostPlatform $postPlatform): array Platform::Bluesky => app(BlueskyAnalytics::class)->fetchPostMetrics($postPlatform), Platform::Mastodon => app(MastodonAnalytics::class)->fetchPostMetrics($postPlatform), Platform::Telegram => app(TelegramAnalytics::class)->fetchPostMetrics($postPlatform), + Platform::Vk => app(VkAnalytics::class)->fetchPostMetrics($postPlatform), Platform::Discord => app(DiscordAnalytics::class)->fetchPostMetrics($postPlatform), Platform::Instagram, Platform::InstagramFacebook => app(InstagramAnalytics::class)->fetchPostMetrics($postPlatform), Platform::Facebook => app(FacebookAnalytics::class)->fetchPostMetrics($postPlatform), diff --git a/app/Services/Social/ConnectionVerifier.php b/app/Services/Social/ConnectionVerifier.php index e0ef33965..80b6840b2 100644 --- a/app/Services/Social/ConnectionVerifier.php +++ b/app/Services/Social/ConnectionVerifier.php @@ -14,6 +14,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; @@ -22,6 +23,7 @@ use App\Services\Social\Meta\GraphError; use App\Services\Social\Telegram\TelegramApi; use App\Support\GoogleBusinessResourceName; +use App\Services\Social\Vk\VkApi; use Illuminate\Http\Client\PendingRequest; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Http; @@ -203,6 +205,7 @@ private function callVerifyEndpoint(SocialAccount $account): bool Platform::Telegram => $this->verifyTelegram($account), Platform::Discord => $this->verifyDiscord($account), Platform::GoogleBusiness => $this->verifyGoogleBusiness($account), + Platform::Vk => $this->verifyVk($account), }; } @@ -801,4 +804,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/VkAnalytics.php b/app/Services/Social/Vk/VkAnalytics.php new file mode 100644 index 000000000..136eaa06e --- /dev/null +++ b/app/Services/Social/Vk/VkAnalytics.php @@ -0,0 +1,104 @@ + + */ + public function getMetrics(SocialAccount $account): array + { + $ownerId = (int) (data_get($account->meta, 'owner_id') ?? $account->platform_user_id); + + try { + if ($ownerId < 0) { + $response = Http::asForm()->post(VkApi::endpoint('groups.getById'), [ + 'group_id' => abs($ownerId), + 'fields' => 'members_count', + ] + VkApi::baseParams($account->access_token))->json(); + + // v5.199 отдаёт response.groups[], более старые версии — response[]. + $count = data_get($response, 'response.groups.0.members_count') + ?? data_get($response, 'response.0.members_count'); + } else { + $response = Http::asForm()->post(VkApi::endpoint('users.get'), [ + 'user_ids' => $ownerId, + 'fields' => 'followers_count', + ] + VkApi::baseParams($account->access_token))->json(); + + $count = data_get($response, 'response.0.followers_count'); + } + } catch (Throwable) { + return []; + } + + if (! is_int($count)) { + return []; + } + + return [ + ['label' => __('analytics.metrics.subscribers'), 'value' => $count], + ]; + } + + /** + * Post-level metrics from wall.getById: views, likes, reposts, comments. + * + * @return array + */ + public function fetchPostMetrics(PostPlatform $postPlatform): array + { + $account = $postPlatform->socialAccount; + $postId = $postPlatform->platform_post_id; + + if (! $account || ! $postId) { + return []; + } + + $ownerId = (int) (data_get($account->meta, 'owner_id') ?? $account->platform_user_id); + + try { + $response = Http::asForm()->post(VkApi::endpoint('wall.getById'), [ + 'posts' => "{$ownerId}_{$postId}", + ] + VkApi::baseParams($account->access_token))->json(); + } catch (Throwable) { + return []; + } + + // v5.199 отдаёт response.items[], более старые версии — response[]. + $post = data_get($response, 'response.items.0') ?? data_get($response, 'response.0'); + + if (! is_array($post)) { + return []; + } + + $metrics = []; + + foreach ([ + 'views.count' => 'analytics.metrics.views', + 'likes.count' => 'analytics.metrics.likes', + 'reposts.count' => 'analytics.metrics.reposts', + 'comments.count' => 'analytics.metrics.comments', + ] as $path => $labelKey) { + $value = data_get($post, $path); + + if (is_int($value)) { + $metrics[] = ['label' => __($labelKey), 'value' => $value]; + } + } + + return $metrics; + } +} diff --git a/app/Services/Social/Vk/VkApi.php b/app/Services/Social/Vk/VkApi.php new file mode 100644 index 000000000..6d5b9602e --- /dev/null +++ b/app/Services/Social/Vk/VkApi.php @@ -0,0 +1,36 @@ + $accessToken, + 'v' => (string) config('trypost.platforms.vk.api_version'), + ]; + } +} diff --git a/app/Services/Social/VkPublisher.php b/app/Services/Social/VkPublisher.php new file mode 100644 index 000000000..7cfe62356 --- /dev/null +++ b/app/Services/Social/VkPublisher.php @@ -0,0 +1,276 @@ +validateContentLength($postPlatform); + + $content = $postPlatform->post->content + ? app(ContentSanitizer::class)->sanitize($postPlatform->post->content, $postPlatform->platform) + : null; + + $account = $postPlatform->socialAccount; + $ownerId = $this->ownerId($account); + + $attachments = []; + + foreach ($postPlatform->post->mediaItems->take($postPlatform->platform->maxImages()) as $media) { + $attachment = match (true) { + $media->isImage() => $this->uploadPhoto($account, $ownerId, $media->url), + $media->isVideo() => $this->uploadVideo($account, $ownerId, $media->url, $content), + default => null, + }; + + if ($attachment !== null) { + $attachments[] = $attachment; + } + } + + $params = [ + 'owner_id' => $ownerId, + 'message' => $content ?? '', + ]; + + if ($ownerId < 0) { + // Publish as the community itself, not as the connecting user. + $params['from_group'] = 1; + } + + if ($attachments !== []) { + $params['attachments'] = implode(',', $attachments); + } + + $response = $this->socialHttp()->asForm()->post( + VkApi::endpoint('wall.post'), + $params + VkApi::baseParams($account->access_token), + ); + + $postId = $response->json('response.post_id'); + + if ($response->failed() || $postId === null) { + Log::error('VK post creation failed', [ + 'status' => $response->status(), + 'body' => $this->redactResponseBody($response->body()), + ]); + $this->handleApiError($response); + } + + return [ + 'id' => (string) $postId, + 'url' => "https://vk.com/wall{$ownerId}_{$postId}", + ]; + } + + /** + * The wall to publish to: negative id for a community, positive for the + * user's own profile wall. Stored at connect time; platform_user_id keeps + * the same value as a fallback for rows created before meta existed. + */ + private function ownerId(SocialAccount $account): int + { + return (int) (data_get($account->meta, 'owner_id') ?? $account->platform_user_id); + } + + /** + * VK photo upload is a three-step flow: getWallUploadServer → POST the + * file to the returned upload_url → saveWallPhoto. Returns an attachment + * reference like `photo123_456`, or null to skip the item (a failed + * single photo should not sink the whole post; wall.post itself decides + * whether an empty post is acceptable). + */ + private function uploadPhoto(SocialAccount $account, int $ownerId, string $url): ?string + { + $groupParams = $ownerId < 0 ? ['group_id' => abs($ownerId)] : []; + $tempFile = tempnam(sys_get_temp_dir(), 'vk_media_'); + + try { + $download = Http::withOptions(['sink' => $tempFile])->timeout(600)->get($url); + + if ($download->failed() || filesize($tempFile) === 0) { + Log::error('VK failed to download media', ['url' => $url]); + + return null; + } + + $detectedMime = mime_content_type($tempFile) ?: ''; + if (MediaType::classify($detectedMime) === MediaType::Image && ! MediaType::isGif($detectedMime)) { + $optimizer = app(MediaOptimizer::class); + $optimizedPath = $optimizer->optimizeImage($tempFile, Platform::Vk); + @unlink($tempFile); + $tempFile = $optimizedPath; + } + + $server = $this->call($account, 'photos.getWallUploadServer', $groupParams); + $uploadUrl = data_get($server, 'response.upload_server') ?? data_get($server, 'response.upload_url'); + + if (! is_string($uploadUrl) || $uploadUrl === '') { + Log::error('VK getWallUploadServer returned no upload_url', ['body' => $this->redactResponseBody(json_encode($server) ?: '')]); + + return null; + } + + $stream = fopen($tempFile, 'r'); + $upload = $this->socialHttp() + ->attach('photo', $stream, 'photo.jpg') + ->post($uploadUrl); + + if (is_resource($stream)) { + fclose($stream); + } + + if ($upload->failed() || data_get($upload->json(), 'photo') === null) { + Log::error('VK photo upload failed', [ + 'status' => $upload->status(), + 'body' => $this->redactResponseBody($upload->body()), + ]); + + return null; + } + + $saved = $this->call($account, 'photos.saveWallPhoto', $groupParams + [ + 'photo' => (string) data_get($upload->json(), 'photo'), + 'server' => (string) data_get($upload->json(), 'server'), + 'hash' => (string) data_get($upload->json(), 'hash'), + ]); + + $photo = data_get($saved, 'response.0'); + + if (! is_array($photo)) { + Log::error('VK saveWallPhoto failed', ['body' => $this->redactResponseBody(json_encode($saved) ?: '')]); + + return null; + } + + return 'photo'.data_get($photo, 'owner_id').'_'.data_get($photo, 'id'); + } catch (\Exception $e) { + Log::error('VK photo upload error', ['error' => $e->getMessage(), 'url' => $url]); + + return null; + } finally { + @unlink($tempFile); + } + } + + /** + * VK video upload: video.save returns an upload_url the raw file is + * POSTed to; the attachment id comes from video.save itself. `wallpost=0` + * keeps VK from auto-publishing — the video is attached to our wall.post. + * Requires the token to carry the `video` scope; a missing scope surfaces + * as an API error and the item is skipped. + */ + private function uploadVideo(SocialAccount $account, int $ownerId, string $url, ?string $content): ?string + { + $tempFile = tempnam(sys_get_temp_dir(), 'vk_video_'); + + try { + $download = Http::withOptions(['sink' => $tempFile])->timeout(600)->get($url); + + if ($download->failed() || filesize($tempFile) === 0) { + Log::error('VK failed to download video', ['url' => $url]); + + return null; + } + + $name = $content !== null && $content !== '' + ? mb_substr($content, 0, 100) + : 'Video'; + + $save = $this->call($account, 'video.save', array_filter([ + 'group_id' => $ownerId < 0 ? abs($ownerId) : null, + 'name' => $name, + 'wallpost' => 0, + ], fn ($value) => $value !== null)); + + $uploadUrl = data_get($save, 'response.upload_url'); + + if (! is_string($uploadUrl) || $uploadUrl === '') { + Log::error('VK video.save returned no upload_url', ['body' => $this->redactResponseBody(json_encode($save) ?: '')]); + + return null; + } + + $stream = fopen($tempFile, 'r'); + $upload = $this->socialHttp() + ->timeout(600) + ->attach('video_file', $stream, 'video.mp4') + ->post($uploadUrl); + + if (is_resource($stream)) { + fclose($stream); + } + + if ($upload->failed()) { + Log::error('VK video upload failed', [ + 'status' => $upload->status(), + 'body' => $this->redactResponseBody($upload->body()), + ]); + + return null; + } + + $videoOwner = data_get($save, 'response.owner_id'); + $videoId = data_get($upload->json(), 'video_id') ?? data_get($save, 'response.video_id'); + + if ($videoOwner === null || $videoId === null) { + return null; + } + + return "video{$videoOwner}_{$videoId}"; + } catch (\Exception $e) { + Log::error('VK video upload error', ['error' => $e->getMessage(), 'url' => $url]); + + return null; + } finally { + @unlink($tempFile); + } + } + + /** + * Call a VK API method and return the decoded body. VK signals errors as + * HTTP 200 + an `error` object, so both transport failures and API errors + * funnel through the same exception path. + * + * @return array + */ + private function call(SocialAccount $account, string $method, array $params): array + { + $response = $this->socialHttp()->asForm()->post( + VkApi::endpoint($method), + $params + VkApi::baseParams($account->access_token), + ); + + if ($response->failed() || $response->json('error') !== null) { + Log::error("VK {$method} failed", [ + 'status' => $response->status(), + 'body' => $this->redactResponseBody($response->body()), + ]); + $this->handleApiError($response); + } + + return $response->json() ?? []; + } + + private function handleApiError(Response $response): never + { + throw VkPublishException::fromApiResponse($response); + } +} diff --git a/config/trypost.php b/config/trypost.php index 6b065c0f4..2b3ee5caf 100644 --- a/config/trypost.php +++ b/config/trypost.php @@ -261,6 +261,11 @@ // Secret-token header Telegram echoes on every webhook call. 'webhook_secret' => env('TELEGRAM_WEBHOOK_SECRET'), ], + 'vk' => [ + 'enabled' => env('VK_ENABLED', true), + 'api' => env('VK_API', 'https://api.vk.com/method'), + 'api_version' => env('VK_API_VERSION', '5.199'), + ], 'discord' => [ 'enabled' => env('DISCORD_ENABLED', true), // Single shared bot application. OAuth (bot scope) authorizes adding the diff --git a/database/factories/PostPlatformFactory.php b/database/factories/PostPlatformFactory.php index 72da643ee..af65c1432 100644 --- a/database/factories/PostPlatformFactory.php +++ b/database/factories/PostPlatformFactory.php @@ -99,6 +99,14 @@ public function bluesky(): static ]); } + public function vk(): static + { + return $this->state(fn (array $attributes) => [ + 'platform' => Platform::Vk, + 'content_type' => ContentType::VkPost, + ]); + } + public function mastodon(): static { return $this->state(fn (array $attributes) => [ diff --git a/database/factories/SocialAccountFactory.php b/database/factories/SocialAccountFactory.php index 3087f9fb9..5265e6005 100644 --- a/database/factories/SocialAccountFactory.php +++ b/database/factories/SocialAccountFactory.php @@ -137,6 +137,21 @@ public function bluesky(): static ]); } + public function vk(): static + { + return $this->state(fn (array $attributes) => [ + 'platform' => Platform::Vk, + 'platform_user_id' => '-123456', + 'scopes' => Platform::Vk->requiredPublishScopes(), + 'token_expires_at' => null, + 'meta' => [ + 'owner_id' => -123456, + 'is_group' => true, + 'vk_user_id' => 111, + ], + ]); + } + public function mastodon(): static { return $this->state(fn (array $attributes) => [ diff --git a/lang/ar/accounts.php b/lang/ar/accounts.php index 204b0dae4..32ab83904 100644 --- a/lang/ar/accounts.php +++ b/lang/ar/accounts.php @@ -42,6 +42,7 @@ 'telegram' => 'اربط قناة أو مجموعة على Telegram', 'discord' => 'اربط خادم Discord', 'google_business' => 'اربط موقع Google Business Profile', + 'vk' => 'اربط مجتمع VK أو ملفًا شخصيًا', ], 'disconnect_modal' => [ @@ -63,6 +64,27 @@ 'submitting' => 'جارٍ الاتصال...', ], + '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' => 'جارٍ الاتصال...', + ], + 'mastodon' => [ 'title' => 'ربط Mastodon', 'description' => 'أدخل خادم Mastodon الخاص بك', diff --git a/lang/ar/posts.php b/lang/ar/posts.php index 5bd4cb358..07259f96e 100644 --- a/lang/ar/posts.php +++ b/lang/ar/posts.php @@ -602,6 +602,10 @@ 'label' => 'منشور', 'description' => 'يظهر على ملفك التجاري في البحث والخرائط', ], + 'vk_post' => [ + 'label' => 'منشور', + 'description' => 'منشور نصي مع وسائط اختيارية', + ], ], 'platforms' => [ @@ -727,6 +731,7 @@ 'mastodon_post' => 'منشور Mastodon', 'telegram_post' => 'منشور Telegram', 'discord_message' => 'رسالة Discord', + 'vk_post' => 'منشور VK', 'facebook_post' => 'منشور Facebook', 'pinterest_pin' => 'دبوس Pinterest', 'instagram_story' => 'قصة Instagram', diff --git a/lang/de/accounts.php b/lang/de/accounts.php index d6e306f26..5b43569df 100644 --- a/lang/de/accounts.php +++ b/lang/de/accounts.php @@ -44,6 +44,7 @@ 'telegram' => 'Verbinde einen Telegram-Kanal oder eine Telegram-Gruppe', 'discord' => 'Verbinde einen Discord-Server', 'google_business' => 'Verbinde einen Google Unternehmensprofil-Standort', + 'vk' => 'Verbinde eine VK-Community oder ein Profil', ], 'disconnect_modal' => [ @@ -65,6 +66,27 @@ 'submitting' => 'Verbindung wird hergestellt...', ], + 'vk' => [ + 'title' => 'VK verbinden', + 'description' => 'In einer Community oder auf deiner Pinnwand veröffentlichen', + 'access_token' => 'Zugriffstoken', + 'access_token_placeholder' => 'vk1.a.…', + 'access_token_hint' => 'Empfohlen: ein Community-Zugriffstoken (Community → Verwalten → API-Nutzung → Zugriffstokens; Fotos, Wall und Community-Verwaltung erlauben) — das Veröffentlichen funktioniert mit jedem App-Typ. Video-Upload erlaubt VK mit Community-Tokens nicht. Ein Benutzer-Zugriffstoken mit den Berechtigungen wall, photos, groups, video, offline funktioniert ebenfalls, aber Wall-Posts erlaubt VK nur Standalone-Apps.', + 'pick_target' => 'Wo veröffentlichen?', + 'target_group' => 'Community', + 'target_profile' => 'Persönliches Profil', + 'invalid_token' => 'VK hat dieses Token abgelehnt.', + 'invalid_target' => 'Diese Pinnwand ist mit dem angegebenen Token nicht verwaltbar.', + 'community' => 'Community', + 'community_placeholder' => 'vk.com/yourclub', + 'community_hint' => 'VK verrät nicht, zu welcher Community ein Schlüssel gehört. Gib daher ihre Adresse oder ihren Kurznamen ein — die Zugehörigkeit wird anschließend geprüft.', + 'invalid_community' => 'Community nicht gefunden. Gib die Adresse im Format vk.com/yourclub ein.', + 'community_token_mismatch' => 'Dieser Schlüssel gehört zu einer anderen Community.', + 'connection_error' => 'Fehler beim Verbinden mit VK. Bitte erneut versuchen.', + 'submit' => 'VK verbinden', + 'submitting' => 'Verbinden...', + ], + 'mastodon' => [ 'title' => 'Mastodon verbinden', 'description' => 'Gib deine Mastodon-Instanz ein', diff --git a/lang/de/posts.php b/lang/de/posts.php index f8ab34c30..8f726f65d 100644 --- a/lang/de/posts.php +++ b/lang/de/posts.php @@ -604,6 +604,10 @@ 'label' => 'Beitrag', 'description' => 'Wird in deinem Geschäftsprofil in Suche und Karten angezeigt', ], + 'vk_post' => [ + 'label' => 'Beitrag', + 'description' => 'Textbeitrag mit optionalen Medien', + ], ], 'platforms' => [ @@ -729,6 +733,7 @@ 'mastodon_post' => 'Mastodon-Beitrag', 'telegram_post' => 'Telegram-Beitrag', 'discord_message' => 'Discord-Nachricht', + 'vk_post' => 'VK-Beitrag', 'facebook_post' => 'Facebook-Beitrag', 'pinterest_pin' => 'Pinterest-Pin', 'instagram_story' => 'Instagram-Story', diff --git a/lang/el/accounts.php b/lang/el/accounts.php index d66f1fb71..748a52f57 100644 --- a/lang/el/accounts.php +++ b/lang/el/accounts.php @@ -42,6 +42,7 @@ 'telegram' => 'Συνδέστε ένα κανάλι ή ομάδα Telegram', 'discord' => 'Συνδέστε έναν διακομιστή Discord', 'google_business' => 'Συνδέστε μια τοποθεσία Google Business Profile', + 'vk' => 'Συνδέστε μια κοινότητα ή ένα προφίλ VK', ], 'disconnect_modal' => [ @@ -63,6 +64,27 @@ 'submitting' => 'Σύνδεση...', ], + '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' => 'Σύνδεση...', + ], + 'mastodon' => [ 'title' => 'Σύνδεση Mastodon', 'description' => 'Εισάγετε το instance του Mastodon σας', diff --git a/lang/el/posts.php b/lang/el/posts.php index 4da982a4f..c12716fde 100644 --- a/lang/el/posts.php +++ b/lang/el/posts.php @@ -602,6 +602,10 @@ 'label' => 'Δημοσίευση', 'description' => 'Εμφανίζεται στο Επιχειρηματικό σας Προφίλ στην Αναζήτηση και τους Χάρτες', ], + 'vk_post' => [ + 'label' => 'Ανάρτηση', + 'description' => 'Ανάρτηση κειμένου με προαιρετικά πολυμέσα', + ], ], 'platforms' => [ @@ -727,6 +731,7 @@ 'mastodon_post' => 'Δημοσίευση Mastodon', 'telegram_post' => 'Δημοσίευση Telegram', 'discord_message' => 'Μήνυμα Discord', + 'vk_post' => 'Ανάρτηση VK', 'facebook_post' => 'Δημοσίευση Facebook', 'pinterest_pin' => 'Pin Pinterest', 'instagram_story' => 'Story Instagram', diff --git a/lang/en/accounts.php b/lang/en/accounts.php index 9e5882aff..1dadf26c5 100644 --- a/lang/en/accounts.php +++ b/lang/en/accounts.php @@ -42,6 +42,7 @@ 'telegram' => 'Connect a Telegram channel or group', 'discord' => 'Connect a Discord server', 'google_business' => 'Connect a Google Business Profile location', + 'vk' => 'Connect a VK community or profile', ], 'disconnect_modal' => [ @@ -63,6 +64,27 @@ 'submitting' => 'Connecting...', ], + 'vk' => [ + 'title' => 'Connect VK', + 'description' => 'Publish to a community or your profile wall', + 'access_token' => 'Access token', + 'access_token_placeholder' => 'vk1.a.…', + 'access_token_hint' => 'Recommended: a community access token (community → Manage → API usage → Access tokens; grant photos, wall and community management) — publishing works with any app type. VK does not allow video upload with community tokens. A user access token with the wall, photos, groups, video, offline scopes also works, but VK only lets standalone apps post to walls.', + 'pick_target' => 'Choose where to publish', + 'target_group' => 'Community', + 'target_profile' => 'Personal profile', + 'invalid_token' => 'VK rejected this token.', + 'invalid_target' => 'This wall cannot be managed with the provided token.', + 'community' => 'Community', + 'community_placeholder' => 'vk.com/yourclub', + 'community_hint' => 'VK cannot tell which community a key belongs to, so enter its address or screen name — the key is then checked against it.', + 'invalid_community' => 'Community not found. Enter its address like vk.com/yourclub.', + 'community_token_mismatch' => 'This key belongs to a different community.', + 'connection_error' => 'Error connecting to VK. Please try again.', + 'submit' => 'Connect VK', + 'submitting' => 'Connecting...', + ], + 'mastodon' => [ 'title' => 'Connect Mastodon', 'description' => 'Enter your Mastodon instance', diff --git a/lang/en/posts.php b/lang/en/posts.php index 12a91b91d..29cd3782d 100644 --- a/lang/en/posts.php +++ b/lang/en/posts.php @@ -602,6 +602,10 @@ 'label' => 'Post', 'description' => 'Appears on your Business Profile in Search and Maps', ], + 'vk_post' => [ + 'label' => 'Post', + 'description' => 'Text post with optional media', + ], ], 'platforms' => [ @@ -727,6 +731,7 @@ 'mastodon_post' => 'Mastodon Post', 'telegram_post' => 'Telegram Post', 'discord_message' => 'Discord Message', + 'vk_post' => 'VK Post', 'facebook_post' => 'Facebook Post', 'pinterest_pin' => 'Pinterest Pin', 'instagram_story' => 'Instagram Story', diff --git a/lang/es/accounts.php b/lang/es/accounts.php index 684628a3b..8b38a632f 100644 --- a/lang/es/accounts.php +++ b/lang/es/accounts.php @@ -42,6 +42,7 @@ 'telegram' => 'Conecta un canal o grupo de Telegram', 'discord' => 'Conecta un servidor de Discord', 'google_business' => 'Conecta una ubicación de Google Business Profile', + 'vk' => 'Conecta una comunidad o un perfil de VK', ], 'disconnect_modal' => [ @@ -63,6 +64,27 @@ 'submitting' => 'Conectando...', ], + 'vk' => [ + 'title' => 'Conectar VK', + 'description' => 'Publica en una comunidad o en tu propio muro', + 'access_token' => 'Token de acceso', + 'access_token_placeholder' => 'vk1.a.…', + 'access_token_hint' => 'Recomendado: un token de acceso de comunidad (comunidad → Administrar → Uso de la API → Tokens de acceso; concede fotos, muro y gestión de la comunidad): la publicación funciona con cualquier tipo de aplicación. VK no permite subir vídeos con tokens de comunidad. También sirve un token de usuario con los permisos wall, photos, groups, video, offline, pero VK solo permite publicar en el muro a las aplicaciones standalone.', + 'pick_target' => 'Dónde publicar', + 'target_group' => 'Comunidad', + 'target_profile' => 'Perfil personal', + 'invalid_token' => 'VK rechazó este token.', + 'invalid_target' => 'Este muro no se puede gestionar con el token indicado.', + 'community' => 'Comunidad', + 'community_placeholder' => 'vk.com/yourclub', + 'community_hint' => 'VK no indica a qué comunidad pertenece una clave, así que introduce su dirección o nombre corto: después se comprueba que la clave le pertenece.', + 'invalid_community' => 'Comunidad no encontrada. Introduce una dirección como vk.com/yourclub.', + 'community_token_mismatch' => 'Esta clave pertenece a otra comunidad.', + 'connection_error' => 'Error al conectar con VK. Inténtalo de nuevo.', + 'submit' => 'Conectar VK', + 'submitting' => 'Conectando...', + ], + 'mastodon' => [ 'title' => 'Conectar Mastodon', 'description' => 'Introduce tu instancia de Mastodon', diff --git a/lang/es/posts.php b/lang/es/posts.php index ce9c2e387..2139bf277 100644 --- a/lang/es/posts.php +++ b/lang/es/posts.php @@ -602,6 +602,10 @@ 'label' => 'Publicación', 'description' => 'Aparece en tu Perfil Empresarial en Búsqueda y Mapas', ], + 'vk_post' => [ + 'label' => 'Publicación', + 'description' => 'Publicación de texto con medios opcionales', + ], ], 'platforms' => [ @@ -728,6 +732,7 @@ 'mastodon_post' => 'Post en Mastodon', 'telegram_post' => 'Post en Telegram', 'discord_message' => 'Mensaje de Discord', + 'vk_post' => 'Publicación de VK', 'facebook_post' => 'Post en Facebook', 'pinterest_pin' => 'Pin de Pinterest', 'instagram_story' => 'Story de Instagram', diff --git a/lang/fr/accounts.php b/lang/fr/accounts.php index 7a1a4f2d0..94c5f36c3 100644 --- a/lang/fr/accounts.php +++ b/lang/fr/accounts.php @@ -42,6 +42,7 @@ 'telegram' => 'Connectez un canal ou un groupe Telegram', 'discord' => 'Connectez un serveur Discord', 'google_business' => 'Connectez un établissement Google Business Profile', + 'vk' => 'Connectez une communauté ou un profil VK', ], 'disconnect_modal' => [ @@ -63,6 +64,27 @@ 'submitting' => 'Connexion...', ], + 'vk' => [ + 'title' => 'Connecter VK', + 'description' => 'Publier dans une communauté ou sur votre mur', + 'access_token' => 'Jeton d\'accès', + 'access_token_placeholder' => 'vk1.a.…', + 'access_token_hint' => 'Recommandé : un token d\'accès de communauté (communauté → Gérer → Utilisation de l\'API → Tokens d\'accès ; accordez photos, mur et gestion de la communauté) — la publication fonctionne avec tout type d\'application. VK n\'autorise pas l\'envoi de vidéos avec un token de communauté. Un token utilisateur avec les permissions wall, photos, groups, video, offline fonctionne aussi, mais VK ne laisse publier sur le mur qu\'aux applications standalone.', + 'pick_target' => 'Où publier', + 'target_group' => 'Communauté', + 'target_profile' => 'Profil personnel', + 'invalid_token' => 'VK a rejeté ce jeton.', + 'invalid_target' => 'Ce mur ne peut pas être géré avec le jeton fourni.', + 'community' => 'Communauté', + 'community_placeholder' => 'vk.com/yourclub', + 'community_hint' => 'VK n’indique pas à quelle communauté appartient une clé : saisissez son adresse ou son nom court — l’appartenance de la clé est ensuite vérifiée.', + 'invalid_community' => 'Communauté introuvable. Saisissez une adresse comme vk.com/yourclub.', + 'community_token_mismatch' => 'Cette clé appartient à une autre communauté.', + 'connection_error' => 'Erreur de connexion à VK. Veuillez réessayer.', + 'submit' => 'Connecter VK', + 'submitting' => 'Connexion...', + ], + 'mastodon' => [ 'title' => 'Connecter Mastodon', 'description' => 'Saisissez votre instance Mastodon', diff --git a/lang/fr/posts.php b/lang/fr/posts.php index 59b72e357..5d9e0fc35 100644 --- a/lang/fr/posts.php +++ b/lang/fr/posts.php @@ -602,6 +602,10 @@ 'label' => 'Publication', 'description' => 'Apparaît dans votre Profil Entreprise dans la Recherche et Cartes', ], + 'vk_post' => [ + 'label' => 'Publication', + 'description' => 'Publication texte avec médias facultatifs', + ], ], 'platforms' => [ @@ -727,6 +731,7 @@ 'mastodon_post' => 'Publication Mastodon', 'telegram_post' => 'Publication Telegram', 'discord_message' => 'Message Discord', + 'vk_post' => 'Publication VK', 'facebook_post' => 'Publication Facebook', 'pinterest_pin' => 'Épingle Pinterest', 'instagram_story' => 'Story Instagram', diff --git a/lang/it/accounts.php b/lang/it/accounts.php index 1ea5fd2a9..bce99dc99 100644 --- a/lang/it/accounts.php +++ b/lang/it/accounts.php @@ -42,6 +42,7 @@ 'telegram' => 'Collega un canale o gruppo Telegram', 'discord' => 'Collega un server Discord', 'google_business' => 'Collega una sede di Google Business Profile', + 'vk' => 'Collega una community o un profilo VK', ], 'disconnect_modal' => [ @@ -63,6 +64,27 @@ 'submitting' => 'Collegamento in corso...', ], + 'vk' => [ + 'title' => 'Collega VK', + 'description' => 'Pubblica in una community o sulla tua bacheca', + 'access_token' => 'Token di accesso', + 'access_token_placeholder' => 'vk1.a.…', + 'access_token_hint' => 'Consigliato: un token di accesso della community (community → Gestisci → Utilizzo API → Token di accesso; concedi foto, bacheca e gestione della community) — la pubblicazione funziona con qualsiasi tipo di app. VK non consente il caricamento di video con i token della community. Funziona anche un token utente con i permessi wall, photos, groups, video, offline, ma VK consente di pubblicare in bacheca solo alle app standalone.', + 'pick_target' => 'Dove pubblicare', + 'target_group' => 'Community', + 'target_profile' => 'Profilo personale', + 'invalid_token' => 'VK ha rifiutato questo token.', + 'invalid_target' => 'Questa bacheca non è gestibile con il token fornito.', + 'community' => 'Community', + 'community_placeholder' => 'vk.com/yourclub', + 'community_hint' => 'VK non indica a quale community appartiene una chiave: inserisci il suo indirizzo o nome breve — l’appartenenza della chiave viene poi verificata.', + 'invalid_community' => 'Community non trovata. Inserisci un indirizzo come vk.com/yourclub.', + 'community_token_mismatch' => 'Questa chiave appartiene a un’altra community.', + 'connection_error' => 'Errore di connessione a VK. Riprova.', + 'submit' => 'Collega VK', + 'submitting' => 'Connessione...', + ], + 'mastodon' => [ 'title' => 'Collega Mastodon', 'description' => 'Inserisci la tua istanza Mastodon', diff --git a/lang/it/posts.php b/lang/it/posts.php index c03e75e11..e553e22d4 100644 --- a/lang/it/posts.php +++ b/lang/it/posts.php @@ -602,6 +602,10 @@ 'label' => 'Pubblicazione', 'description' => 'Appare nel tuo Profilo Aziendale in Ricerca e Mappe', ], + 'vk_post' => [ + 'label' => 'Post', + 'description' => 'Post di testo con media facoltativi', + ], ], 'platforms' => [ @@ -727,6 +731,7 @@ 'mastodon_post' => 'Post Mastodon', 'telegram_post' => 'Post Telegram', 'discord_message' => 'Messaggio Discord', + 'vk_post' => 'Post VK', 'facebook_post' => 'Post Facebook', 'pinterest_pin' => 'Pin Pinterest', 'instagram_story' => 'Storia Instagram', diff --git a/lang/ja/accounts.php b/lang/ja/accounts.php index 0f0d63295..a619734dd 100644 --- a/lang/ja/accounts.php +++ b/lang/ja/accounts.php @@ -42,6 +42,7 @@ 'telegram' => 'Telegram チャンネルまたはグループを接続', 'discord' => 'Discord サーバーを接続', 'google_business' => 'Google ビジネス プロフィールの店舗を接続', + 'vk' => 'VKのコミュニティまたはプロフィールを連携', ], 'disconnect_modal' => [ @@ -63,6 +64,27 @@ 'submitting' => '接続中...', ], + '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' => '接続中...', + ], + 'mastodon' => [ 'title' => 'Mastodon を接続', 'description' => 'Mastodon のインスタンスを入力してください', diff --git a/lang/ja/posts.php b/lang/ja/posts.php index e066e8686..ba23821d8 100644 --- a/lang/ja/posts.php +++ b/lang/ja/posts.php @@ -602,6 +602,10 @@ 'label' => '投稿', 'description' => 'ビジネス プロフィールに検索とマップで表示されます', ], + 'vk_post' => [ + 'label' => '投稿', + 'description' => 'メディア添付可能なテキスト投稿', + ], ], 'platforms' => [ @@ -727,6 +731,7 @@ 'mastodon_post' => 'Mastodon 投稿', 'telegram_post' => 'Telegram 投稿', 'discord_message' => 'Discord メッセージ', + 'vk_post' => 'VK投稿', 'facebook_post' => 'Facebook 投稿', 'pinterest_pin' => 'Pinterest ピン', 'instagram_story' => 'Instagram ストーリー', diff --git a/lang/ko/accounts.php b/lang/ko/accounts.php index 86b63384f..a3835b095 100644 --- a/lang/ko/accounts.php +++ b/lang/ko/accounts.php @@ -42,6 +42,7 @@ 'telegram' => 'Telegram 채널 또는 그룹을 연결하세요', 'discord' => 'Discord 서버를 연결하세요', 'google_business' => 'Google 비즈니스 프로필 위치를 연결하세요', + 'vk' => 'VK 커뮤니티 또는 프로필 연결', ], 'disconnect_modal' => [ @@ -63,6 +64,27 @@ 'submitting' => '연결 중...', ], + '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' => '연결 중...', + ], + 'mastodon' => [ 'title' => 'Mastodon 연결', 'description' => 'Mastodon 인스턴스를 입력하세요', diff --git a/lang/ko/posts.php b/lang/ko/posts.php index 13bfc4421..90e619887 100644 --- a/lang/ko/posts.php +++ b/lang/ko/posts.php @@ -602,6 +602,10 @@ 'label' => '게시물', 'description' => '비즈니스 프로필에 검색 및 지도에 표시됩니다', ], + 'vk_post' => [ + 'label' => '게시물', + 'description' => '미디어를 선택적으로 첨부할 수 있는 텍스트 게시물', + ], ], 'platforms' => [ @@ -727,6 +731,7 @@ 'mastodon_post' => 'Mastodon 게시물', 'telegram_post' => 'Telegram 게시물', 'discord_message' => 'Discord 메시지', + 'vk_post' => 'VK 게시물', 'facebook_post' => 'Facebook 게시물', 'pinterest_pin' => 'Pinterest 핀', 'instagram_story' => 'Instagram 스토리', diff --git a/lang/nl/accounts.php b/lang/nl/accounts.php index 07ddc9a97..2c8411684 100644 --- a/lang/nl/accounts.php +++ b/lang/nl/accounts.php @@ -42,6 +42,7 @@ 'telegram' => 'Koppel een Telegram-kanaal of -groep', 'discord' => 'Koppel een Discord-server', 'google_business' => 'Koppel een Google Bedrijfsprofiel-locatie', + 'vk' => 'Verbind een VK-community of -profiel', ], 'disconnect_modal' => [ @@ -63,6 +64,27 @@ 'submitting' => 'Koppelen...', ], + 'vk' => [ + 'title' => 'VK verbinden', + 'description' => 'Publiceer in een community of op je eigen prikbord', + 'access_token' => 'Toegangstoken', + 'access_token_placeholder' => 'vk1.a.…', + 'access_token_hint' => 'Aanbevolen: een community-toegangstoken (community → Beheren → API-gebruik → Toegangstokens; geef foto\'s, prikbord en communitybeheer) — publiceren werkt met elk app-type. Video-upload staat VK met community-tokens niet toe. Een gebruikerstoken met de rechten wall, photos, groups, video, offline werkt ook, maar VK laat alleen standalone-apps op het prikbord posten.', + 'pick_target' => 'Waar publiceren', + 'target_group' => 'Community', + 'target_profile' => 'Persoonlijk profiel', + 'invalid_token' => 'VK heeft dit token geweigerd.', + 'invalid_target' => 'Dit prikbord is niet beheerbaar met het opgegeven token.', + 'community' => 'Community', + 'community_placeholder' => 'vk.com/yourclub', + 'community_hint' => 'VK vertelt niet bij welke community een sleutel hoort. Voer daarom het adres of de korte naam in — daarna wordt gecontroleerd of de sleutel erbij hoort.', + 'invalid_community' => 'Community niet gevonden. Voer een adres in zoals vk.com/yourclub.', + 'community_token_mismatch' => 'Deze sleutel hoort bij een andere community.', + 'connection_error' => 'Fout bij verbinden met VK. Probeer het opnieuw.', + 'submit' => 'VK verbinden', + 'submitting' => 'Verbinden...', + ], + 'mastodon' => [ 'title' => 'Mastodon koppelen', 'description' => 'Voer je Mastodon-instance in', diff --git a/lang/nl/posts.php b/lang/nl/posts.php index 3338a4118..f4184e8c4 100644 --- a/lang/nl/posts.php +++ b/lang/nl/posts.php @@ -602,6 +602,10 @@ 'label' => 'Bericht', 'description' => 'Wordt weergegeven in je Bedrijfsprofiel in Zoeken en Kaarten', ], + 'vk_post' => [ + 'label' => 'Bericht', + 'description' => 'Tekstbericht met optionele media', + ], ], 'platforms' => [ @@ -727,6 +731,7 @@ 'mastodon_post' => 'Mastodon-post', 'telegram_post' => 'Telegram-post', 'discord_message' => 'Discord-bericht', + 'vk_post' => 'VK-bericht', 'facebook_post' => 'Facebook-post', 'pinterest_pin' => 'Pinterest-pin', 'instagram_story' => 'Instagram-story', diff --git a/lang/pl/accounts.php b/lang/pl/accounts.php index af434c67e..c41f2d1a8 100644 --- a/lang/pl/accounts.php +++ b/lang/pl/accounts.php @@ -42,6 +42,7 @@ 'telegram' => 'Połącz kanał lub grupę na Telegramie', 'discord' => 'Połącz serwer Discord', 'google_business' => 'Połącz lokalizację Google Business Profile', + 'vk' => 'Połącz społeczność lub profil VK', ], 'disconnect_modal' => [ @@ -63,6 +64,27 @@ 'submitting' => 'Łączenie...', ], + 'vk' => [ + 'title' => 'Połącz VK', + 'description' => 'Publikuj w społeczności lub na własnej tablicy', + 'access_token' => 'Token dostępu', + 'access_token_placeholder' => 'vk1.a.…', + 'access_token_hint' => 'Zalecane: klucz dostępu społeczności (społeczność → Zarządzanie → Praca z API → Klucze dostępu; zaznacz zdjęcia, tablicę i zarządzanie społecznością) — publikacja działa z każdym typem aplikacji. VK nie pozwala przesyłać wideo kluczem społeczności. Zadziała też token użytkownika z uprawnieniami wall, photos, groups, video, offline, ale publikować na tablicy VK pozwala tylko aplikacjom standalone.', + 'pick_target' => 'Gdzie publikować', + 'target_group' => 'Społeczność', + 'target_profile' => 'Profil osobisty', + 'invalid_token' => 'VK odrzucił ten token.', + 'invalid_target' => 'Tą tablicą nie można zarządzać podanym tokenem.', + 'community' => 'Społeczność', + 'community_placeholder' => 'vk.com/yourclub', + 'community_hint' => 'VK nie ujawnia, do której społeczności należy klucz, więc podaj jej adres lub krótką nazwę — przynależność klucza zostanie następnie sprawdzona.', + 'invalid_community' => 'Nie znaleziono społeczności. Podaj adres w formie vk.com/yourclub.', + 'community_token_mismatch' => 'Ten klucz należy do innej społeczności.', + 'connection_error' => 'Błąd połączenia z VK. Spróbuj ponownie.', + 'submit' => 'Połącz VK', + 'submitting' => 'Łączenie...', + ], + 'mastodon' => [ 'title' => 'Połącz Mastodon', 'description' => 'Wprowadź swoją instancję Mastodon', diff --git a/lang/pl/posts.php b/lang/pl/posts.php index 8deb478fd..008e25915 100644 --- a/lang/pl/posts.php +++ b/lang/pl/posts.php @@ -602,6 +602,10 @@ 'label' => 'Post', 'description' => 'Pojawia się w twoim Profilu Biznesowym w Wyszukiwaniu i Mapach', ], + 'vk_post' => [ + 'label' => 'Post', + 'description' => 'Post tekstowy z opcjonalnymi mediami', + ], ], 'platforms' => [ @@ -727,6 +731,7 @@ 'mastodon_post' => 'Post na Mastodon', 'telegram_post' => 'Post na Telegramie', 'discord_message' => 'Wiadomość na Discord', + 'vk_post' => 'Post VK', 'facebook_post' => 'Post na Facebooku', 'pinterest_pin' => 'Pin na Pinterest', 'instagram_story' => 'Relacja na Instagramie', diff --git a/lang/pt-BR/accounts.php b/lang/pt-BR/accounts.php index eea4987d8..b221112d4 100644 --- a/lang/pt-BR/accounts.php +++ b/lang/pt-BR/accounts.php @@ -42,6 +42,7 @@ 'telegram' => 'Conecte um canal ou grupo do Telegram', 'discord' => 'Conecte um servidor do Discord', 'google_business' => 'Conecte um local do Google Business Profile', + 'vk' => 'Conecte uma comunidade ou um perfil do VK', ], 'disconnect_modal' => [ @@ -63,6 +64,27 @@ 'submitting' => 'Conectando...', ], + 'vk' => [ + 'title' => 'Conectar VK', + 'description' => 'Publique em uma comunidade ou no seu mural', + 'access_token' => 'Token de acesso', + 'access_token_placeholder' => 'vk1.a.…', + 'access_token_hint' => 'Recomendado: um token de acesso da comunidade (comunidade → Gerenciar → Uso da API → Tokens de acesso; conceda fotos, mural e gestão da comunidade) — a publicação funciona com qualquer tipo de app. O VK não permite envio de vídeo com tokens de comunidade. Um token de usuário com os escopos wall, photos, groups, video, offline também funciona, mas o VK só permite postar no mural para apps standalone.', + 'pick_target' => 'Onde publicar', + 'target_group' => 'Comunidade', + 'target_profile' => 'Perfil pessoal', + 'invalid_token' => 'O VK rejeitou este token.', + 'invalid_target' => 'Este mural não pode ser gerenciado com o token informado.', + 'community' => 'Comunidade', + 'community_placeholder' => 'vk.com/yourclub', + 'community_hint' => 'O VK não informa a qual comunidade uma chave pertence; informe o endereço ou nome curto dela — a chave é então verificada.', + 'invalid_community' => 'Comunidade não encontrada. Informe um endereço como vk.com/yourclub.', + 'community_token_mismatch' => 'Esta chave pertence a outra comunidade.', + 'connection_error' => 'Erro ao conectar ao VK. Tente novamente.', + 'submit' => 'Conectar VK', + 'submitting' => 'Conectando...', + ], + 'mastodon' => [ 'title' => 'Conectar Mastodon', 'description' => 'Digite a instância do seu Mastodon', diff --git a/lang/pt-BR/posts.php b/lang/pt-BR/posts.php index c6d04ee18..5d0c0c311 100644 --- a/lang/pt-BR/posts.php +++ b/lang/pt-BR/posts.php @@ -602,6 +602,10 @@ 'label' => 'Publicação', 'description' => 'Aparece no seu Perfil Empresarial em Pesquisa e Mapas', ], + 'vk_post' => [ + 'label' => 'Publicação', + 'description' => 'Publicação de texto com mídia opcional', + ], ], 'platforms' => [ @@ -727,6 +731,7 @@ 'mastodon_post' => 'Post no Mastodon', 'telegram_post' => 'Post no Telegram', 'discord_message' => 'Mensagem do Discord', + 'vk_post' => 'Publicação do VK', 'facebook_post' => 'Post no Facebook', 'pinterest_pin' => 'Pin no Pinterest', 'instagram_story' => 'Story do Instagram', diff --git a/lang/ru/accounts.php b/lang/ru/accounts.php index 1efee374a..3e17b83d1 100644 --- a/lang/ru/accounts.php +++ b/lang/ru/accounts.php @@ -42,6 +42,7 @@ 'telegram' => 'Подключите канал или группу Telegram', 'discord' => 'Подключите сервер Discord', 'google_business' => 'Подключите местоположение Google Business Profile', + 'vk' => 'Подключите сообщество или профиль VK', ], 'disconnect_modal' => [ @@ -63,6 +64,27 @@ 'submitting' => 'Подключение...', ], + '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' => 'Подключение...', + ], + 'mastodon' => [ 'title' => 'Подключить Mastodon', 'description' => 'Укажите свой сервер Mastodon', diff --git a/lang/ru/posts.php b/lang/ru/posts.php index 4d557f7f4..33a1391bc 100644 --- a/lang/ru/posts.php +++ b/lang/ru/posts.php @@ -602,6 +602,10 @@ 'label' => 'Пост', 'description' => 'Отображается в вашем Профиле компании в Поиске и Картах', ], + 'vk_post' => [ + 'label' => 'Пост', + 'description' => 'Текстовый пост с необязательными медиа', + ], ], 'platforms' => [ @@ -727,6 +731,7 @@ 'mastodon_post' => 'Пост Mastodon', 'telegram_post' => 'Пост Telegram', 'discord_message' => 'Сообщение Discord', + 'vk_post' => 'Пост VK', 'facebook_post' => 'Пост Facebook', 'pinterest_pin' => 'Пин Pinterest', 'instagram_story' => 'История Instagram', diff --git a/lang/tr/accounts.php b/lang/tr/accounts.php index c6e976b5f..ec5725ac5 100644 --- a/lang/tr/accounts.php +++ b/lang/tr/accounts.php @@ -44,6 +44,7 @@ 'telegram' => 'Bir Telegram kanalı veya grubu bağlayın', 'discord' => 'Bir Discord sunucusu bağlayın', 'google_business' => 'Bir Google İşletme Profili konumu bağlayın', + 'vk' => 'Bir VK topluluğu veya profili bağlayın', ], 'disconnect_modal' => [ @@ -65,6 +66,27 @@ 'submitting' => 'Bağlanıyor...', ], + 'vk' => [ + 'title' => 'VK\'yı bağla', + 'description' => 'Bir topluluğa veya kendi duvarınıza gönderi yayınlayın', + 'access_token' => 'Erişim belirteci', + 'access_token_placeholder' => 'vk1.a.…', + 'access_token_hint' => 'Önerilen: topluluk erişim anahtarı (topluluk → Yönet → API kullanımı → Erişim anahtarları; fotoğraflar, duvar ve topluluk yönetimi izinlerini verin) — yayınlama her uygulama türüyle çalışır. VK, topluluk anahtarıyla video yüklemeye izin vermez. wall, photos, groups, video, offline izinli bir kullanıcı anahtarı da çalışır, ancak VK duvara gönderiyi yalnızca standalone uygulamalara açar.', + 'pick_target' => 'Nerede yayınlansın', + 'target_group' => 'Topluluk', + 'target_profile' => 'Kişisel profil', + 'invalid_token' => 'VK bu belirteci reddetti.', + 'invalid_target' => 'Bu duvar, verilen belirteçle yönetilemiyor.', + 'community' => 'Topluluk', + 'community_placeholder' => 'vk.com/yourclub', + 'community_hint' => 'VK bir anahtarın hangi topluluğa ait olduğunu bildirmez; topluluğun adresini veya kısa adını girin — anahtarın aidiyeti ardından doğrulanır.', + 'invalid_community' => 'Topluluk bulunamadı. vk.com/yourclub biçiminde bir adres girin.', + 'community_token_mismatch' => 'Bu anahtar başka bir topluluğa ait.', + 'connection_error' => 'VK\'ya bağlanırken hata oluştu. Lütfen tekrar deneyin.', + 'submit' => 'VK\'yı bağla', + 'submitting' => 'Bağlanıyor...', + ], + 'mastodon' => [ 'title' => 'Mastodon\'u Bağla', 'description' => 'Mastodon sunucunuzu girin', diff --git a/lang/tr/posts.php b/lang/tr/posts.php index b47e9c531..562bfa76e 100644 --- a/lang/tr/posts.php +++ b/lang/tr/posts.php @@ -604,6 +604,10 @@ 'label' => 'Gönderi', 'description' => 'İşletme Profilinde Arama ve Haritalar\'da görünür', ], + 'vk_post' => [ + 'label' => 'Gönderi', + 'description' => 'İsteğe bağlı medya içeren metin gönderisi', + ], ], 'platforms' => [ @@ -729,6 +733,7 @@ 'mastodon_post' => 'Mastodon Gönderisi', 'telegram_post' => 'Telegram Gönderisi', 'discord_message' => 'Discord Mesajı', + 'vk_post' => 'VK Gönderisi', 'facebook_post' => 'Facebook Gönderisi', 'pinterest_pin' => 'Pinterest Pin\'i', 'instagram_story' => 'Instagram Hikayesi', diff --git a/lang/uk/accounts.php b/lang/uk/accounts.php index e3f9402fd..065ae4684 100644 --- a/lang/uk/accounts.php +++ b/lang/uk/accounts.php @@ -42,6 +42,7 @@ 'telegram' => 'Підключіть канал або групу Telegram', 'discord' => 'Підключіть сервер Discord', 'google_business' => 'Підключіть місцезнаходження Google Business Profile', + 'vk' => 'Підключіть спільноту або профіль VK', ], 'disconnect_modal' => [ @@ -63,6 +64,27 @@ 'submitting' => 'Підключення...', ], + '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' => 'Підключення...', + ], + 'mastodon' => [ 'title' => 'Підключити Mastodon', 'description' => 'Введіть інстанс Mastodon', diff --git a/lang/uk/posts.php b/lang/uk/posts.php index 5fc6a6cd2..40cea193f 100644 --- a/lang/uk/posts.php +++ b/lang/uk/posts.php @@ -602,6 +602,10 @@ 'label' => 'Публікація', 'description' => 'Відображається у вашому Профілі компанії в Пошуку та Картах', ], + 'vk_post' => [ + 'label' => 'Пост', + 'description' => 'Текстовий пост із необов\'язковими медіа', + ], ], 'platforms' => [ @@ -727,6 +731,7 @@ 'mastodon_post' => 'Пост Mastodon', 'telegram_post' => 'Пост Telegram', 'discord_message' => 'Повідомлення Discord', + 'vk_post' => 'Пост VK', 'facebook_post' => 'Пост Facebook', 'pinterest_pin' => 'Pin Pinterest', 'instagram_story' => 'Stories Instagram', diff --git a/lang/zh/accounts.php b/lang/zh/accounts.php index 691f8778d..ad1217542 100644 --- a/lang/zh/accounts.php +++ b/lang/zh/accounts.php @@ -42,6 +42,7 @@ 'telegram' => '连接一个 Telegram 频道或群组', 'discord' => '连接一个 Discord 服务器', 'google_business' => '连接一个 Google 商家资料位置', + 'vk' => '连接 VK 社群或个人主页', ], 'disconnect_modal' => [ @@ -63,6 +64,27 @@ 'submitting' => '连接中…', ], + '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' => '连接中...', + ], + 'mastodon' => [ 'title' => '连接 Mastodon', 'description' => '输入你的 Mastodon 实例', diff --git a/lang/zh/posts.php b/lang/zh/posts.php index 271cfeff9..f5966ad15 100644 --- a/lang/zh/posts.php +++ b/lang/zh/posts.php @@ -602,6 +602,10 @@ 'label' => '帖子', 'description' => '在搜索和地图中显示在您的商业资料中', ], + 'vk_post' => [ + 'label' => '帖子', + 'description' => '可附带媒体的文字帖子', + ], ], 'platforms' => [ @@ -727,6 +731,7 @@ 'mastodon_post' => 'Mastodon 帖子', 'telegram_post' => 'Telegram 帖子', 'discord_message' => 'Discord 消息', + 'vk_post' => 'VK 帖子', 'facebook_post' => 'Facebook 帖子', 'pinterest_pin' => 'Pinterest Pin', 'instagram_story' => 'Instagram 快拍', diff --git a/public/images/accounts/vk.png b/public/images/accounts/vk.png new file mode 100644 index 000000000..3ad605a5d Binary files /dev/null and b/public/images/accounts/vk.png differ diff --git a/resources/js/components/analytics/VkAnalytics.vue b/resources/js/components/analytics/VkAnalytics.vue new file mode 100644 index 000000000..44994bffb --- /dev/null +++ b/resources/js/components/analytics/VkAnalytics.vue @@ -0,0 +1,57 @@ + + + diff --git a/resources/js/components/posts/previews/PlatformPreview.vue b/resources/js/components/posts/previews/PlatformPreview.vue index 490528c69..b7c3f3a5f 100644 --- a/resources/js/components/posts/previews/PlatformPreview.vue +++ b/resources/js/components/posts/previews/PlatformPreview.vue @@ -16,6 +16,7 @@ import PinterestPreview from './PinterestPreview.vue'; import TelegramPreview from './TelegramPreview.vue'; import ThreadsPreview from './ThreadsPreview.vue'; import TikTokPreview from './TikTokPreview.vue'; +import VkPreview from './VkPreview.vue'; import XPreview from './XPreview.vue'; import YouTubePreview from './YouTubePreview.vue'; @@ -91,6 +92,8 @@ const previewComponent = computed(() => { return DiscordPreview; case 'google_business': return GoogleBusinessPreview; + case 'vk': + return VkPreview; default: return LinkedInPreview; } diff --git a/resources/js/components/posts/previews/VkPreview.vue b/resources/js/components/posts/previews/VkPreview.vue new file mode 100644 index 000000000..2c50171de --- /dev/null +++ b/resources/js/components/posts/previews/VkPreview.vue @@ -0,0 +1,133 @@ + + + diff --git a/resources/js/composables/useOAuthPopup.ts b/resources/js/composables/useOAuthPopup.ts index 350c1b36c..623e8dd79 100644 --- a/resources/js/composables/useOAuthPopup.ts +++ b/resources/js/composables/useOAuthPopup.ts @@ -14,6 +14,7 @@ import { connect as pinterestConnect } from '@/routes/app/social/pinterest'; import { connect as threadsConnect } from '@/routes/app/social/threads'; import { connect as tiktokConnect } from '@/routes/app/social/tiktok'; import { connect as xConnect } from '@/routes/app/social/x'; +import { connect as vkConnect } from '@/routes/app/social/vk'; import { connect as youtubeConnect } from '@/routes/app/social/youtube'; import { Platform } from '@/types/platform'; @@ -34,6 +35,7 @@ const CONNECT_ROUTES: Record = { telegram: '/images/accounts/telegram.png', discord: '/images/accounts/discord.png', google_business: '/images/accounts/google_business.png', + vk: '/images/accounts/vk.png', }; const PLATFORM_LABELS: Record = { @@ -32,6 +33,7 @@ const PLATFORM_LABELS: Record = { telegram: 'Telegram', discord: 'Discord', google_business: 'Google Business Profile', + vk: 'VK', }; const PLATFORM_CONTENT_TYPES: Record = { @@ -54,6 +56,7 @@ const PLATFORM_CONTENT_TYPES: Record = { telegram: ['telegram_post'], discord: ['discord_message'], google_business: ['google_business_post'], + vk: ['vk_post'], }; export interface ContentTypeOption { @@ -77,6 +80,7 @@ const PLATFORM_THEMES: Record = { telegram: { bg: 'bg-sky-200', rotate: '-rotate-2' }, discord: { bg: 'bg-indigo-200', rotate: 'rotate-1' }, google_business: { bg: 'bg-blue-100', rotate: 'rotate-2' }, + vk: { bg: 'bg-blue-200', rotate: '-rotate-1' }, }; export const getPlatformLogo = (platform: string): string => diff --git a/resources/js/pages/accounts/VkConnect.vue b/resources/js/pages/accounts/VkConnect.vue new file mode 100644 index 000000000..f8b20adab --- /dev/null +++ b/resources/js/pages/accounts/VkConnect.vue @@ -0,0 +1,142 @@ + + + diff --git a/resources/js/pages/analytics/Index.vue b/resources/js/pages/analytics/Index.vue index 917fe7587..89f9f73fc 100644 --- a/resources/js/pages/analytics/Index.vue +++ b/resources/js/pages/analytics/Index.vue @@ -15,6 +15,7 @@ import TikTokAnalytics from '@/components/analytics/TikTokAnalytics.vue'; import type { AnalyticsAccount } from '@/components/analytics/types'; import XAnalytics from '@/components/analytics/XAnalytics.vue'; import YouTubeAnalytics from '@/components/analytics/YouTubeAnalytics.vue'; +import VkAnalytics from '@/components/analytics/VkAnalytics.vue'; import PageHeader from '@/components/PageHeader.vue'; import { DateRangePicker } from '@/components/ui/date-range-picker'; import dayjs from '@/dayjs'; @@ -149,6 +150,11 @@ const platformSupportsDateRange = computed(() => { :account-id="selectedAccountId" /> + +
name('app.social.pinterest.connect'); Route::get('connect/bluesky', [BlueskyController::class, 'connect'])->name('app.social.bluesky.connect'); Route::post('connect/bluesky', [BlueskyController::class, 'store'])->name('app.social.bluesky.store'); + Route::get('connect/vk', [VkController::class, 'connect'])->name('app.social.vk.connect'); + Route::post('connect/vk', [VkController::class, 'store'])->name('app.social.vk.store'); Route::get('connect/mastodon', [MastodonController::class, 'connect'])->name('app.social.mastodon.connect'); Route::post('connect/mastodon', [MastodonController::class, 'authorizeInstance'])->name('app.social.mastodon.authorize'); Route::post('connect/telegram', [TelegramController::class, 'connect'])->name('app.social.telegram.connect'); diff --git a/tests/Feature/Services/Social/ConnectionVerifierTest.php b/tests/Feature/Services/Social/ConnectionVerifierTest.php index 56259b4c4..8f87670b8 100644 --- a/tests/Feature/Services/Social/ConnectionVerifierTest.php +++ b/tests/Feature/Services/Social/ConnectionVerifierTest.php @@ -1306,3 +1306,46 @@ expect($account->fresh()->refresh_token)->toBe('refresh-rotated'); }); + +test('verifies a vk community-token account via groups.getById', function () { + $api = rtrim((string) config('trypost.platforms.vk.api'), '/'); + + Http::fake([ + "{$api}/groups.getById*" => Http::response([ + 'response' => ['groups' => [['id' => 123456, 'name' => 'Test Community']]], + ], 200), + ]); + + $account = SocialAccount::factory()->vk()->create([ + 'meta' => [ + 'owner_id' => -123456, + 'is_group' => true, + 'community_token' => true, + ], + ]); + + $verifier = new ConnectionVerifier; + + expect($verifier->verify($account))->toBeTrue(); + + Http::assertSentCount(1); + Http::assertSent(fn ($request) => str_contains($request->url(), '/groups.getById')); +}); + +test('verifies a vk user-token account via users.get', function () { + $api = rtrim((string) config('trypost.platforms.vk.api'), '/'); + + Http::fake([ + "{$api}/users.get*" => Http::response([ + 'response' => [['id' => 111, 'first_name' => 'Test', 'last_name' => 'User']], + ], 200), + ]); + + $account = SocialAccount::factory()->vk()->create(); + + $verifier = new ConnectionVerifier; + + expect($verifier->verify($account))->toBeTrue(); + + Http::assertSent(fn ($request) => str_contains($request->url(), '/users.get')); +}); diff --git a/tests/Feature/Services/Social/VkAnalyticsTest.php b/tests/Feature/Services/Social/VkAnalyticsTest.php new file mode 100644 index 000000000..1903d9c7e --- /dev/null +++ b/tests/Feature/Services/Social/VkAnalyticsTest.php @@ -0,0 +1,77 @@ +user = User::factory()->create(); + $this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]); + $this->account = SocialAccount::factory()->vk()->create(['workspace_id' => $this->workspace->id]); + $this->analytics = new VkAnalytics; + $this->api = rtrim((string) config('trypost.platforms.vk.api'), '/'); +}); + +test('vk analytics returns the community member count', function () { + Http::fake([ + "{$this->api}/groups.getById*" => Http::response([ + 'response' => ['groups' => [['id' => 123456, 'members_count' => 4321]]], + ], 200), + ]); + + $metrics = $this->analytics->getMetrics($this->account); + + expect($metrics)->toHaveCount(1) + ->and($metrics[0]['value'])->toBe(4321); +}); + +test('vk analytics returns post views, likes, reposts and comments', function () { + $post = Post::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'user_id' => $this->user->id, + 'content' => 'x', + ]); + $row = PostPlatform::factory()->create([ + 'post_id' => $post->id, + 'social_account_id' => $this->account->id, + 'platform' => Platform::Vk, + 'content_type' => ContentType::VkPost, + 'platform_post_id' => '42', + ]); + + Http::fake([ + "{$this->api}/wall.getById*" => Http::response([ + 'response' => ['items' => [[ + 'views' => ['count' => 100], + 'likes' => ['count' => 10], + 'reposts' => ['count' => 3], + 'comments' => ['count' => 5], + ]]], + ], 200), + ]); + + $metrics = $this->analytics->fetchPostMetrics($row); + + expect(array_column($metrics, 'value'))->toBe([100, 10, 3, 5]); + + Http::assertSent(fn ($request) => str_contains($request->url(), '/wall.getById') + && $request['posts'] === '-123456_42'); +}); + +test('vk analytics returns empty metrics on api error', function () { + Http::fake([ + "{$this->api}/groups.getById*" => Http::response([ + 'error' => ['error_code' => 5, 'error_msg' => 'auth failed'], + ], 200), + ]); + + expect($this->analytics->getMetrics($this->account))->toBe([]); +}); diff --git a/tests/Feature/Services/Social/VkPublisherTest.php b/tests/Feature/Services/Social/VkPublisherTest.php new file mode 100644 index 000000000..e6dc771f0 --- /dev/null +++ b/tests/Feature/Services/Social/VkPublisherTest.php @@ -0,0 +1,127 @@ +user = User::factory()->create(); + $this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]); + + $this->socialAccount = SocialAccount::factory()->vk()->create([ + 'workspace_id' => $this->workspace->id, + 'username' => 'testcommunity', + ]); + + $this->post = Post::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'user_id' => $this->user->id, + 'content' => 'Hello from VK!', + ]); + + $this->postPlatform = PostPlatform::factory()->create([ + 'post_id' => $this->post->id, + 'social_account_id' => $this->socialAccount->id, + 'platform' => Platform::Vk, + 'content_type' => ContentType::VkPost, + ]); + + $this->publisher = new VkPublisher; + $this->api = rtrim((string) config('trypost.platforms.vk.api'), '/'); +}); + +test('vk publisher can publish text-only post to a community', function () { + Http::fake([ + "{$this->api}/wall.post*" => Http::response([ + 'response' => ['post_id' => 42], + ], 200), + ]); + + $result = $this->publisher->publish($this->postPlatform); + + expect($result['id'])->toBe('42') + ->and($result['url'])->toBe('https://vk.com/wall-123456_42'); + + Http::assertSent(function ($request) { + return str_contains($request->url(), '/wall.post') + && $request['owner_id'] == -123456 + && $request['from_group'] == 1 + && $request['message'] === 'Hello from VK!' + && $request['v'] === config('trypost.platforms.vk.api_version'); + }); +}); + +test('vk publisher posts to a profile wall without from_group', function () { + $this->socialAccount->update([ + 'platform_user_id' => '111', + 'meta' => ['owner_id' => 111, 'is_group' => false, 'vk_user_id' => 111], + ]); + $this->postPlatform->refresh(); + + Http::fake([ + "{$this->api}/wall.post*" => Http::response([ + 'response' => ['post_id' => 7], + ], 200), + ]); + + $result = $this->publisher->publish($this->postPlatform); + + expect($result['url'])->toBe('https://vk.com/wall111_7'); + + Http::assertSent(function ($request) { + return str_contains($request->url(), '/wall.post') + && $request['owner_id'] == 111 + && ! isset($request['from_group']); + }); +}); + +test('vk publisher throws token expired exception on dead token', function () { + Http::fake([ + "{$this->api}/wall.post*" => Http::response([ + 'error' => [ + 'error_code' => 5, + 'error_msg' => 'User authorization failed: invalid access_token.', + ], + ], 200), + ]); + + $this->publisher->publish($this->postPlatform); +})->throws(TokenExpiredException::class); + +test('vk publisher throws publish exception on api error', function () { + Http::fake([ + "{$this->api}/wall.post*" => Http::response([ + 'error' => [ + 'error_code' => 214, + 'error_msg' => 'Access to adding post denied.', + ], + ], 200), + ]); + + try { + $this->publisher->publish($this->postPlatform); + $this->fail('Expected VkPublishException'); + } catch (VkPublishException $e) { + expect($e->platformErrorCode)->toBe('214') + ->and($e->platform())->toBe('vk'); + } +}); + +test('vk publisher rejects content over the platform limit', function () { + $this->post->update(['content' => str_repeat('a', Platform::Vk->maxContentLength() + 1)]); + $this->postPlatform->refresh(); + + Http::fake(); + + $this->publisher->publish($this->postPlatform); +})->throws(Exception::class, 'Content exceeds VK limit'); diff --git a/tests/Feature/Social/VkControllerTest.php b/tests/Feature/Social/VkControllerTest.php new file mode 100644 index 000000000..8cafc53db --- /dev/null +++ b/tests/Feature/Social/VkControllerTest.php @@ -0,0 +1,250 @@ +user = User::factory()->create(); + $this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]); + $this->user->update(['current_workspace_id' => $this->workspace->id]); + $this->workspace->members()->attach($this->user->id, ['role' => Role::Member->value]); + + $this->api = rtrim((string) config('trypost.platforms.vk.api'), '/'); +}); + +function fakeVkIdentity(string $api): array +{ + return [ + "{$api}/users.get*" => Http::response([ + 'response' => [ + [ + 'id' => 111, + 'first_name' => 'Test', + 'last_name' => 'User', + 'screen_name' => 'testuser', + 'photo_200' => null, + ], + ], + ], 200), + "{$api}/groups.get*" => Http::response([ + 'response' => [ + 'count' => 1, + 'items' => [ + [ + 'id' => 123456, + 'name' => 'Test Community', + 'screen_name' => 'testcommunity', + 'photo_200' => null, + ], + ], + ], + ], 200), + ]; +} + +test('vk connect page can be rendered', function () { + $response = $this->actingAs($this->user)->get(route('app.social.vk.connect')); + + $response->assertOk(); + $response->assertInertia(fn (AssertableInertia $page) => $page->component('accounts/VkConnect')); +}); + +test('submitting a valid token lists manageable walls', function () { + Http::fake(fakeVkIdentity($this->api)); + + $response = $this->actingAs($this->user)->post(route('app.social.vk.store'), [ + 'access_token' => 'vk1.a.valid-test-token', + ]); + + $response->assertOk(); + $response->assertInertia(fn (AssertableInertia $page) => $page + ->component('accounts/VkConnect') + ->has('targets', 2) + ->where('targets.0.owner_id', 111) + ->where('targets.1.owner_id', -123456) + ->where('targets.1.is_group', true)); + + $this->assertDatabaseMissing('social_accounts', [ + 'workspace_id' => $this->workspace->id, + 'platform' => Platform::Vk->value, + ]); +}); + +test('user can connect a vk community wall', function () { + Http::fake(fakeVkIdentity($this->api)); + + $response = $this->actingAs($this->user)->post(route('app.social.vk.store'), [ + 'access_token' => 'vk1.a.valid-test-token', + 'owner_id' => -123456, + ]); + + $response->assertOk(); + $response->assertInertia(fn (AssertableInertia $page) => $page->component('accounts/PopupCallback')); + $response->assertInertia(fn (AssertableInertia $page) => $page->where('success', true)); + + $this->assertDatabaseHas('social_accounts', [ + 'workspace_id' => $this->workspace->id, + 'platform' => Platform::Vk->value, + 'platform_user_id' => '-123456', + 'username' => 'testcommunity', + 'display_name' => 'Test Community', + 'status' => Status::Connected->value, + ]); +}); + +test('connecting a wall the token does not manage is rejected', function () { + Http::fake(fakeVkIdentity($this->api)); + + $response = $this->actingAs($this->user)->post(route('app.social.vk.store'), [ + 'access_token' => 'vk1.a.valid-test-token', + 'owner_id' => -999999, + ]); + + $response->assertSessionHasErrors('owner_id'); + + $this->assertDatabaseMissing('social_accounts', [ + 'workspace_id' => $this->workspace->id, + 'platform' => Platform::Vk->value, + ]); +}); + +test('a token vk rejects surfaces the api error on the token field', function () { + Http::fake([ + "{$this->api}/users.get*" => Http::response([ + 'error' => [ + 'error_code' => 5, + 'error_msg' => 'User authorization failed: invalid access_token.', + ], + ], 200), + ]); + + $response = $this->actingAs($this->user)->post(route('app.social.vk.store'), [ + 'access_token' => 'vk1.a.revoked-token', + ]); + + $response->assertSessionHasErrors('access_token'); +}); + +function fakeVkCommunityToken(string $api): array +{ + return [ + // users.get принимает ключ сообщества, но без user_ids отвечает + // пустым списком — так и распознаётся ключ сообщества. + "{$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)); + + $response = $this->actingAs($this->user)->post(route('app.social.vk.store'), [ + 'access_token' => 'vk1.a.community-token', + 'community' => 'https://vk.com/njsoft', + ]); + + $response->assertOk(); + $response->assertInertia(fn (AssertableInertia $page) => $page + ->component('accounts/PopupCallback') + ->where('success', true)); + + Http::assertSent(fn ($request) => str_contains($request->url(), '/groups.getById') + && $request['group_ids'] === 'njsoft'); + + $this->assertDatabaseHas('social_accounts', [ + 'workspace_id' => $this->workspace->id, + 'platform' => Platform::Vk->value, + 'platform_user_id' => '-654321', + 'username' => 'njsoft', + 'display_name' => 'NJ Soft', + 'status' => Status::Connected->value, + ]); + + $account = $this->workspace->socialAccounts()->where('platform', Platform::Vk->value)->first(); + + expect(data_get($account->meta, 'community_token'))->toBeTrue() + ->and(data_get($account->meta, 'owner_id'))->toBe(-654321) + ->and(data_get($account->meta, 'is_group'))->toBeTrue(); +}); + +test('a community token 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/PlatformTest.php b/tests/Unit/Enums/PlatformTest.php index 7f0bb590f..947b033a6 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'); +});