diff --git a/app/Enums/PostPlatform/ContentType.php b/app/Enums/PostPlatform/ContentType.php index 0d56e74cd..e68dcbfbf 100644 --- a/app/Enums/PostPlatform/ContentType.php +++ b/app/Enums/PostPlatform/ContentType.php @@ -56,6 +56,9 @@ enum ContentType: string // Discord case DiscordMessage = 'discord_message'; + // VK + case VkPost = 'vk_post'; + /** * AI generation format for an Instagram carousel. Not a content type — * carousel posts are persisted as InstagramFeed. @@ -84,6 +87,7 @@ public function label(): string self::MastodonPost => 'Post', self::TelegramPost => 'Post', self::DiscordMessage => 'Message', + self::VkPost => 'Post', }; } @@ -108,6 +112,7 @@ public function platform(): SocialPlatform self::MastodonPost => SocialPlatform::Mastodon, self::TelegramPost => SocialPlatform::Telegram, self::DiscordMessage => SocialPlatform::Discord, + self::VkPost => SocialPlatform::Vk, }; } @@ -177,6 +182,7 @@ public function maxMediaCount(): int self::MastodonPost => 4, self::TelegramPost => 10, self::DiscordMessage => 10, + self::VkPost => 10, }; } @@ -458,6 +464,7 @@ public function supportsVideo(): bool self::MastodonPost => true, self::TelegramPost => true, self::DiscordMessage => true, + self::VkPost => true, }; } @@ -609,6 +616,7 @@ public static function defaultFor(SocialPlatform $platform): self SocialPlatform::Mastodon => self::MastodonPost, SocialPlatform::Telegram => self::TelegramPost, SocialPlatform::Discord => self::DiscordMessage, + SocialPlatform::Vk => self::VkPost, }; } } diff --git a/app/Enums/SocialAccount/Platform.php b/app/Enums/SocialAccount/Platform.php index 1d9d207c8..320944e93 100644 --- a/app/Enums/SocialAccount/Platform.php +++ b/app/Enums/SocialAccount/Platform.php @@ -22,6 +22,7 @@ enum Platform: string case Mastodon = 'mastodon'; case Telegram = 'telegram'; case Discord = 'discord'; + case Vk = 'vk'; /** * The social network this platform belongs to. Variants that represent the @@ -69,6 +70,7 @@ public function label(): string self::Mastodon => 'Mastodon', self::Telegram => 'Telegram', self::Discord => 'Discord', + self::Vk => 'VK', }; } @@ -88,6 +90,7 @@ public function color(): string self::Mastodon => '#6364FF', self::Telegram => '#26A5E4', self::Discord => '#5865F2', + self::Vk => '#0077FF', }; } @@ -106,6 +109,7 @@ public function allowedMediaTypes(): array self::Mastodon => [MediaType::Image, MediaType::Video], self::Telegram => [MediaType::Image, MediaType::Video], self::Discord => [MediaType::Image, MediaType::Video], + self::Vk => [MediaType::Image, MediaType::Video], }; } @@ -124,6 +128,7 @@ public function maxImages(): int self::Mastodon => 4, self::Telegram => 10, self::Discord => 10, + self::Vk => 10, }; } @@ -147,7 +152,7 @@ public function altTextMaxLength(): ?int self::Threads => 1000, self::Pinterest => 500, self::Discord => 1024, - self::TikTok, self::YouTube, self::Telegram => null, + self::TikTok, self::YouTube, self::Telegram, self::Vk => null, }; } @@ -181,6 +186,7 @@ public function supportsAltText(): bool * - Mastodon: 500 default; instances may be higher (we stay conservative) * - Telegram: 4096 for a text message (media captions are capped at 1024, * handled in the publisher by sending long text as its own message) + * - VK: 15895 characters for a wall post */ public function maxContentLength(): int { @@ -197,6 +203,7 @@ public function maxContentLength(): int self::Mastodon => 500, self::Telegram => 4096, self::Discord => 2000, + self::Vk => 15895, }; } @@ -242,6 +249,8 @@ public function recommendedAiContentLength(): int self::Telegram => 400, // Discord — conversational community posts read best when concise self::Discord => 280, + // VK — feed favors short posts; long reads live in Articles + self::Vk => 400, }; } @@ -265,6 +274,7 @@ public function requiredPublishScopes(): array self::Mastodon => ['write:statuses'], self::Telegram => [], self::Discord => [], + self::Vk => [], }; } @@ -283,6 +293,7 @@ public function supportsTextOnly(): bool self::Mastodon => true, self::Telegram => true, self::Discord => true, + self::Vk => true, }; } diff --git a/app/Exceptions/Social/VkPublishException.php b/app/Exceptions/Social/VkPublishException.php new file mode 100644 index 000000000..ee86ca74c --- /dev/null +++ b/app/Exceptions/Social/VkPublishException.php @@ -0,0 +1,60 @@ +body(); + $error = $response->json('error'); + + // VK reports failures as HTTP 200 with an `error` object; transport + // failures (5xx) have no such object. + $code = (int) data_get($error, 'error_code', 0); + $message = (string) data_get($error, 'error_msg', 'An unknown VK error occurred.'); + + if (self::isConfirmedDeadToken($response)) { + throw new TokenExpiredException( + message: $message, + platformErrorCode: (string) $code, + ); + } + + return new static( + userMessage: $message, + category: match (true) { + in_array($code, [6, 9, 29], true) => ErrorCategory::RateLimit, + in_array($code, [7, 15, 200, 214, 219], true) => ErrorCategory::Permission, + in_array($code, [100, 118, 129], true) => ErrorCategory::MediaFormat, + $code === 0 && $response->serverError() => ErrorCategory::ServerError, + default => ErrorCategory::Unknown, + }, + platformErrorCode: $code > 0 ? (string) $code : (string) $response->status(), + rawResponse: $rawResponse, + ); + } + + public function platform(): string + { + return 'vk'; + } + + /** + * Whether this response confirms the account's own access_token is dead. + * VK error 5 is "User authorization failed" — the token was revoked or + * invalidated (password change, security logout). Shared with + * ConnectionVerifier so publish and verify agree on what a dead token + * looks like. + */ + public static function isConfirmedDeadToken(Response $response): bool + { + return (int) $response->json('error.error_code') === 5; + } +} diff --git a/app/Http/Controllers/App/AnalyticsController.php b/app/Http/Controllers/App/AnalyticsController.php index 8376afe8f..4d1ec508d 100644 --- a/app/Http/Controllers/App/AnalyticsController.php +++ b/app/Http/Controllers/App/AnalyticsController.php @@ -13,6 +13,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; @@ -38,6 +39,7 @@ class AnalyticsController extends Controller Platform::Pinterest, Platform::YouTube, Platform::Telegram, + Platform::Vk, ]; public function index(Request $request): Response @@ -99,6 +101,7 @@ private function metricsFor(SocialAccount $account, ?Carbon $since, ?Carbon $unt Platform::Pinterest => app(PinterestAnalytics::class)->getMetrics($account, $since, $until), Platform::YouTube => app(YouTubeAnalytics::class)->getMetrics($account, $since, $until), Platform::Telegram => app(TelegramAnalytics::class)->getMetrics($account), + 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 106a9f92e..87d99d8cd 100644 --- a/app/Jobs/PublishToSocialPlatform.php +++ b/app/Jobs/PublishToSocialPlatform.php @@ -30,6 +30,7 @@ use App\Services\Social\Telegram\TelegramPublisher; use App\Services\Social\ThreadsPublisher; use App\Services\Social\TikTokPublisher; +use App\Services\Social\VkPublisher; use App\Services\Social\XPublisher; use App\Services\Social\YouTubePublisher; use App\Support\Social\TikTokPhotoDerivativeCleaner; @@ -337,7 +338,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 + private function getPublisher(): LinkedInPublisher|LinkedInPagePublisher|XPublisher|TikTokPublisher|YouTubePublisher|FacebookPublisher|InstagramPublisher|ThreadsPublisher|PinterestPublisher|BlueskyPublisher|MastodonPublisher|TelegramPublisher|DiscordPublisher|VkPublisher { return match ($this->postPlatform->platform) { SocialPlatform::LinkedIn => app(LinkedInPublisher::class), @@ -353,6 +354,7 @@ private function getPublisher(): LinkedInPublisher|LinkedInPagePublisher|XPublis SocialPlatform::Mastodon => app(MastodonPublisher::class), SocialPlatform::Telegram => app(TelegramPublisher::class), SocialPlatform::Discord => app(DiscordPublisher::class), + SocialPlatform::Vk => app(VkPublisher::class), }; } diff --git a/app/Models/SocialAccount.php b/app/Models/SocialAccount.php index 6059bb3ed..fb22e7d29 100644 --- a/app/Models/SocialAccount.php +++ b/app/Models/SocialAccount.php @@ -283,6 +283,9 @@ protected function profileUrl(): Attribute ? rtrim((string) data_get($this->meta, 'instance'), '/')."/@{$username}" : null, SocialPlatform::Telegram => $username ? "https://t.me/{$username}" : 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 3370a4a0c..9569af83f 100644 --- a/app/Services/Media/MediaOptimizer.php +++ b/app/Services/Media/MediaOptimizer.php @@ -354,6 +354,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 b68e3305b..682e877a2 100644 --- a/app/Services/Post/PostMetricsFetcher.php +++ b/app/Services/Post/PostMetricsFetcher.php @@ -15,6 +15,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\XAnalytics; use App\Services\Social\YouTubeAnalytics; @@ -67,6 +68,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 7c38c91f0..14cf65ef0 100644 --- a/app/Services/Social/ConnectionVerifier.php +++ b/app/Services/Social/ConnectionVerifier.php @@ -13,6 +13,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; @@ -20,6 +21,7 @@ use App\Services\Social\Discord\DiscordClient; use App\Services\Social\Meta\GraphError; use App\Services\Social\Telegram\TelegramApi; +use App\Services\Social\Vk\VkApi; use Illuminate\Http\Client\PendingRequest; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Http; @@ -184,6 +186,7 @@ private function callVerifyEndpoint(SocialAccount $account): bool Platform::Mastodon => $this->verifyMastodon($account), Platform::Telegram => $this->verifyTelegram($account), Platform::Discord => $this->verifyDiscord($account), + Platform::Vk => $this->verifyVk($account), }; } @@ -727,4 +730,29 @@ private function verifyMastodon(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 f9d038e4d..f19b23f65 100644 --- a/config/trypost.php +++ b/config/trypost.php @@ -240,6 +240,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 cb39e970f..b66fab83f 100644 --- a/database/factories/PostPlatformFactory.php +++ b/database/factories/PostPlatformFactory.php @@ -90,6 +90,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 927aba12a..e1848b4cf 100644 --- a/database/factories/SocialAccountFactory.php +++ b/database/factories/SocialAccountFactory.php @@ -123,6 +123,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 500991a2d..75d1a2640 100644 --- a/lang/ar/accounts.php +++ b/lang/ar/accounts.php @@ -30,6 +30,7 @@ 'mastodon' => 'اربط حسابك على Mastodon', 'telegram' => 'اربط قناة أو مجموعة على Telegram', 'discord' => 'اربط خادم Discord', + 'vk' => 'اربط مجتمع VK أو ملفًا شخصيًا', ], 'disconnect_modal' => [ @@ -51,6 +52,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 77c703250..aab1c6d44 100644 --- a/lang/ar/posts.php +++ b/lang/ar/posts.php @@ -550,6 +550,10 @@ 'label' => 'رسالة', 'description' => 'رسالة إلى قناة Discord مع وسائط وتضمينات اختيارية', ], + 'vk_post' => [ + 'label' => 'منشور', + 'description' => 'منشور نصي مع وسائط اختيارية', + ], ], 'platforms' => [ @@ -660,6 +664,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 f9574718d..75d0ff0b3 100644 --- a/lang/de/accounts.php +++ b/lang/de/accounts.php @@ -32,6 +32,7 @@ 'mastodon' => 'Verbinde dein Mastodon-Konto', 'telegram' => 'Verbinde einen Telegram-Kanal oder eine Telegram-Gruppe', 'discord' => 'Verbinde einen Discord-Server', + 'vk' => 'Verbinde eine VK-Community oder ein Profil', ], 'disconnect_modal' => [ @@ -53,6 +54,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 a082a28d3..4892c1cbc 100644 --- a/lang/de/posts.php +++ b/lang/de/posts.php @@ -552,6 +552,10 @@ 'label' => 'Nachricht', 'description' => 'Nachricht an einen Discord-Kanal mit optionalen Medien & Embeds', ], + 'vk_post' => [ + 'label' => 'Beitrag', + 'description' => 'Textbeitrag mit optionalen Medien', + ], ], 'platforms' => [ @@ -662,6 +666,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 2b2fffaf7..6f9001dda 100644 --- a/lang/el/accounts.php +++ b/lang/el/accounts.php @@ -30,6 +30,7 @@ 'mastodon' => 'Συνδέστε τον λογαριασμό σας Mastodon', 'telegram' => 'Συνδέστε ένα κανάλι ή ομάδα Telegram', 'discord' => 'Συνδέστε έναν διακομιστή Discord', + 'vk' => 'Συνδέστε μια κοινότητα ή ένα προφίλ VK', ], 'disconnect_modal' => [ @@ -51,6 +52,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 a4592be22..7d24d3417 100644 --- a/lang/el/posts.php +++ b/lang/el/posts.php @@ -550,6 +550,10 @@ 'label' => 'Μήνυμα', 'description' => 'Μήνυμα σε κανάλι Discord με προαιρετικά πολυμέσα και embeds', ], + 'vk_post' => [ + 'label' => 'Ανάρτηση', + 'description' => 'Ανάρτηση κειμένου με προαιρετικά πολυμέσα', + ], ], 'platforms' => [ @@ -660,6 +664,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 ce62dbfdb..e6b9a336e 100644 --- a/lang/en/accounts.php +++ b/lang/en/accounts.php @@ -30,6 +30,7 @@ 'mastodon' => 'Connect your Mastodon account', 'telegram' => 'Connect a Telegram channel or group', 'discord' => 'Connect a Discord server', + 'vk' => 'Connect a VK community or profile', ], 'disconnect_modal' => [ @@ -51,6 +52,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 ffdf6cecf..745b3ffc9 100644 --- a/lang/en/posts.php +++ b/lang/en/posts.php @@ -550,6 +550,10 @@ 'label' => 'Message', 'description' => 'Message to a Discord channel with optional media & embeds', ], + 'vk_post' => [ + 'label' => 'Post', + 'description' => 'Text post with optional media', + ], ], 'platforms' => [ @@ -660,6 +664,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 f1f273393..c76bbc2a2 100644 --- a/lang/es/accounts.php +++ b/lang/es/accounts.php @@ -30,6 +30,7 @@ 'mastodon' => 'Conecta tu cuenta de Mastodon', 'telegram' => 'Conecta un canal o grupo de Telegram', 'discord' => 'Conecta un servidor de Discord', + 'vk' => 'Conecta una comunidad o un perfil de VK', ], 'disconnect_modal' => [ @@ -51,6 +52,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 566e78ecc..2a5bc1351 100644 --- a/lang/es/posts.php +++ b/lang/es/posts.php @@ -550,6 +550,10 @@ 'label' => 'Mensaje', 'description' => 'Mensaje a un canal de Discord con multimedia y embeds opcionales', ], + 'vk_post' => [ + 'label' => 'Publicación', + 'description' => 'Publicación de texto con medios opcionales', + ], ], 'platforms' => [ @@ -661,6 +665,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 7ba02b5fe..672c373e5 100644 --- a/lang/fr/accounts.php +++ b/lang/fr/accounts.php @@ -30,6 +30,7 @@ 'mastodon' => 'Connectez votre compte Mastodon', 'telegram' => 'Connectez un canal ou un groupe Telegram', 'discord' => 'Connectez un serveur Discord', + 'vk' => 'Connectez une communauté ou un profil VK', ], 'disconnect_modal' => [ @@ -51,6 +52,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 b246551f2..7e4ae3642 100644 --- a/lang/fr/posts.php +++ b/lang/fr/posts.php @@ -550,6 +550,10 @@ 'label' => 'Message', 'description' => 'Message vers un salon Discord avec médias et embeds facultatifs', ], + 'vk_post' => [ + 'label' => 'Publication', + 'description' => 'Publication texte avec médias facultatifs', + ], ], 'platforms' => [ @@ -660,6 +664,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 20d2deb5f..14214c9a7 100644 --- a/lang/it/accounts.php +++ b/lang/it/accounts.php @@ -30,6 +30,7 @@ 'mastodon' => 'Collega il tuo account Mastodon', 'telegram' => 'Collega un canale o gruppo Telegram', 'discord' => 'Collega un server Discord', + 'vk' => 'Collega una community o un profilo VK', ], 'disconnect_modal' => [ @@ -51,6 +52,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 8bb0645e9..981ca5df3 100644 --- a/lang/it/posts.php +++ b/lang/it/posts.php @@ -550,6 +550,10 @@ 'label' => 'Messaggio', 'description' => 'Messaggio a un canale Discord con media ed embed facoltativi', ], + 'vk_post' => [ + 'label' => 'Post', + 'description' => 'Post di testo con media facoltativi', + ], ], 'platforms' => [ @@ -660,6 +664,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 23f151420..8f32482a8 100644 --- a/lang/ja/accounts.php +++ b/lang/ja/accounts.php @@ -30,6 +30,7 @@ 'mastodon' => 'Mastodon アカウントを接続', 'telegram' => 'Telegram チャンネルまたはグループを接続', 'discord' => 'Discord サーバーを接続', + 'vk' => 'VKのコミュニティまたはプロフィールを連携', ], 'disconnect_modal' => [ @@ -51,6 +52,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 f0092fb3f..4b41d8308 100644 --- a/lang/ja/posts.php +++ b/lang/ja/posts.php @@ -550,6 +550,10 @@ 'label' => 'メッセージ', 'description' => 'メディアと埋め込み(任意)付きの Discord チャンネルへのメッセージ', ], + 'vk_post' => [ + 'label' => '投稿', + 'description' => 'メディア添付可能なテキスト投稿', + ], ], 'platforms' => [ @@ -660,6 +664,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 48c2c06cc..5212ae18f 100644 --- a/lang/ko/accounts.php +++ b/lang/ko/accounts.php @@ -30,6 +30,7 @@ 'mastodon' => 'Mastodon 계정을 연결하세요', 'telegram' => 'Telegram 채널 또는 그룹을 연결하세요', 'discord' => 'Discord 서버를 연결하세요', + 'vk' => 'VK 커뮤니티 또는 프로필 연결', ], 'disconnect_modal' => [ @@ -51,6 +52,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 2ec32c9a4..ca8e4805c 100644 --- a/lang/ko/posts.php +++ b/lang/ko/posts.php @@ -550,6 +550,10 @@ 'label' => '메시지', 'description' => '선택적 미디어 및 임베드가 있는 Discord 채널 메시지', ], + 'vk_post' => [ + 'label' => '게시물', + 'description' => '미디어를 선택적으로 첨부할 수 있는 텍스트 게시물', + ], ], 'platforms' => [ @@ -660,6 +664,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 835ec17d1..99eaecc57 100644 --- a/lang/nl/accounts.php +++ b/lang/nl/accounts.php @@ -30,6 +30,7 @@ 'mastodon' => 'Koppel je Mastodon-account', 'telegram' => 'Koppel een Telegram-kanaal of -groep', 'discord' => 'Koppel een Discord-server', + 'vk' => 'Verbind een VK-community of -profiel', ], 'disconnect_modal' => [ @@ -51,6 +52,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 2e06df707..ee3828247 100644 --- a/lang/nl/posts.php +++ b/lang/nl/posts.php @@ -550,6 +550,10 @@ 'label' => 'Bericht', 'description' => 'Bericht naar een Discord-kanaal met optionele media en embeds', ], + 'vk_post' => [ + 'label' => 'Bericht', + 'description' => 'Tekstbericht met optionele media', + ], ], 'platforms' => [ @@ -660,6 +664,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 0d5b98a82..ac9f04593 100644 --- a/lang/pl/accounts.php +++ b/lang/pl/accounts.php @@ -30,6 +30,7 @@ 'mastodon' => 'Połącz swoje konto Mastodon', 'telegram' => 'Połącz kanał lub grupę na Telegramie', 'discord' => 'Połącz serwer Discord', + 'vk' => 'Połącz społeczność lub profil VK', ], 'disconnect_modal' => [ @@ -51,6 +52,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 d47721c06..5333af486 100644 --- a/lang/pl/posts.php +++ b/lang/pl/posts.php @@ -550,6 +550,10 @@ 'label' => 'Wiadomość', 'description' => 'Wiadomość na kanale Discord z opcjonalnymi multimediami i osadzeniami', ], + 'vk_post' => [ + 'label' => 'Post', + 'description' => 'Post tekstowy z opcjonalnymi mediami', + ], ], 'platforms' => [ @@ -660,6 +664,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 cb95d308d..b848bffbc 100644 --- a/lang/pt-BR/accounts.php +++ b/lang/pt-BR/accounts.php @@ -30,6 +30,7 @@ 'mastodon' => 'Conecte sua conta do Mastodon', 'telegram' => 'Conecte um canal ou grupo do Telegram', 'discord' => 'Conecte um servidor do Discord', + 'vk' => 'Conecte uma comunidade ou um perfil do VK', ], 'disconnect_modal' => [ @@ -51,6 +52,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 3e4c0a0ec..7d2c4c1a1 100644 --- a/lang/pt-BR/posts.php +++ b/lang/pt-BR/posts.php @@ -550,6 +550,10 @@ 'label' => 'Mensagem', 'description' => 'Mensagem para um canal do Discord com mídia e embeds opcionais', ], + 'vk_post' => [ + 'label' => 'Publicação', + 'description' => 'Publicação de texto com mídia opcional', + ], ], 'platforms' => [ @@ -660,6 +664,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 32023ef44..440ac271a 100644 --- a/lang/ru/accounts.php +++ b/lang/ru/accounts.php @@ -30,6 +30,7 @@ 'mastodon' => 'Подключите аккаунт Mastodon', 'telegram' => 'Подключите канал или группу Telegram', 'discord' => 'Подключите сервер Discord', + 'vk' => 'Подключите сообщество или профиль VK', ], 'disconnect_modal' => [ @@ -51,6 +52,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 19439375f..f216502a1 100644 --- a/lang/ru/posts.php +++ b/lang/ru/posts.php @@ -550,6 +550,10 @@ 'label' => 'Сообщение', 'description' => 'Сообщение в канал Discord с опциональным медиа и встраиваниями', ], + 'vk_post' => [ + 'label' => 'Пост', + 'description' => 'Текстовый пост с необязательными медиа', + ], ], 'platforms' => [ @@ -660,6 +664,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 dafacd2ff..064043ed5 100644 --- a/lang/tr/accounts.php +++ b/lang/tr/accounts.php @@ -32,6 +32,7 @@ 'mastodon' => 'Mastodon hesabınızı bağlayın', 'telegram' => 'Bir Telegram kanalı veya grubu bağlayın', 'discord' => 'Bir Discord sunucusu bağlayın', + 'vk' => 'Bir VK topluluğu veya profili bağlayın', ], 'disconnect_modal' => [ @@ -53,6 +54,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 f19d6c8a1..a539039c7 100644 --- a/lang/tr/posts.php +++ b/lang/tr/posts.php @@ -552,6 +552,10 @@ 'label' => 'Mesaj', 'description' => 'İsteğe bağlı medya ve yerleştirmeler içeren Discord kanalına mesaj', ], + 'vk_post' => [ + 'label' => 'Gönderi', + 'description' => 'İsteğe bağlı medya içeren metin gönderisi', + ], ], 'platforms' => [ @@ -662,6 +666,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 20b510076..ea5fa3b52 100644 --- a/lang/uk/accounts.php +++ b/lang/uk/accounts.php @@ -30,6 +30,7 @@ 'mastodon' => 'Підключіть акаунт Mastodon', 'telegram' => 'Підключіть канал або групу Telegram', 'discord' => 'Підключіть сервер Discord', + 'vk' => 'Підключіть спільноту або профіль VK', ], 'disconnect_modal' => [ @@ -51,6 +52,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 bcd9b2505..77835fec8 100644 --- a/lang/uk/posts.php +++ b/lang/uk/posts.php @@ -550,6 +550,10 @@ 'label' => 'Повідомлення', 'description' => 'Повідомлення в канал Discord із необов’язковим медіа та вбудовуваннями', ], + 'vk_post' => [ + 'label' => 'Пост', + 'description' => 'Текстовий пост із необов\'язковими медіа', + ], ], 'platforms' => [ @@ -660,6 +664,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 bae4ccdc5..c8e433a72 100644 --- a/lang/zh/accounts.php +++ b/lang/zh/accounts.php @@ -30,6 +30,7 @@ 'mastodon' => '连接你的 Mastodon 账号', 'telegram' => '连接一个 Telegram 频道或群组', 'discord' => '连接一个 Discord 服务器', + 'vk' => '连接 VK 社群或个人主页', ], 'disconnect_modal' => [ @@ -51,6 +52,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 af5dc2750..4e25ce1ab 100644 --- a/lang/zh/posts.php +++ b/lang/zh/posts.php @@ -550,6 +550,10 @@ 'label' => '消息', 'description' => '发送到 Discord 频道的消息,可附带媒体和嵌入内容', ], + 'vk_post' => [ + 'label' => '帖子', + 'description' => '可附带媒体的文字帖子', + ], ], 'platforms' => [ @@ -660,6 +664,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/automations/config/GenerateNodeConfig.vue b/resources/js/components/automations/config/GenerateNodeConfig.vue index b3311ed63..68e711bee 100644 --- a/resources/js/components/automations/config/GenerateNodeConfig.vue +++ b/resources/js/components/automations/config/GenerateNodeConfig.vue @@ -127,6 +127,8 @@ const defaultContentTypeFor = (platform: string): string => { return ContentType.BlueskyPost; case Platform.Mastodon: return ContentType.MastodonPost; + case Platform.Vk: + return ContentType.VkPost; default: return ''; } diff --git a/resources/js/components/posts/previews/PlatformPreview.vue b/resources/js/components/posts/previews/PlatformPreview.vue index 69e38bd82..4cfecb964 100644 --- a/resources/js/components/posts/previews/PlatformPreview.vue +++ b/resources/js/components/posts/previews/PlatformPreview.vue @@ -15,6 +15,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'; @@ -88,6 +89,8 @@ const previewComponent = computed(() => { return TelegramPreview; case 'discord': return DiscordPreview; + 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 acde69840..25bd2d5dc 100644 --- a/resources/js/composables/useOAuthPopup.ts +++ b/resources/js/composables/useOAuthPopup.ts @@ -13,6 +13,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'; @@ -32,6 +33,7 @@ const CONNECT_ROUTES: Record = { mastodon: '/images/accounts/mastodon.png', telegram: '/images/accounts/telegram.png', discord: '/images/accounts/discord.png', + vk: '/images/accounts/vk.png', }; const PLATFORM_LABELS: Record = { @@ -30,6 +31,7 @@ const PLATFORM_LABELS: Record = { mastodon: 'Mastodon', telegram: 'Telegram', discord: 'Discord', + vk: 'VK', }; const PLATFORM_CONTENT_TYPES: Record = { @@ -51,6 +53,7 @@ const PLATFORM_CONTENT_TYPES: Record = { mastodon: ['mastodon_post'], telegram: ['telegram_post'], discord: ['discord_message'], + vk: ['vk_post'], }; export interface ContentTypeOption { @@ -73,6 +76,7 @@ const PLATFORM_THEMES: Record = { mastodon: { bg: 'bg-violet-200', rotate: 'rotate-1' }, telegram: { bg: 'bg-sky-200', rotate: '-rotate-2' }, discord: { bg: 'bg-indigo-200', rotate: 'rotate-1' }, + 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 712b00254..7092df041 100644 --- a/resources/js/pages/analytics/Index.vue +++ b/resources/js/pages/analytics/Index.vue @@ -14,6 +14,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'; @@ -141,6 +142,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 48a86d792..70117e72a 100644 --- a/tests/Feature/Services/Social/ConnectionVerifierTest.php +++ b/tests/Feature/Services/Social/ConnectionVerifierTest.php @@ -1200,3 +1200,46 @@ Http::assertSentCount(1); }); + +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 803756c5c..fffdece3b 100644 --- a/tests/Unit/Enums/PlatformTest.php +++ b/tests/Unit/Enums/PlatformTest.php @@ -18,6 +18,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 () { @@ -32,6 +33,7 @@ expect(Platform::Pinterest->color())->toBe('#E60023'); expect(Platform::Bluesky->color())->toBe('#0085FF'); expect(Platform::Mastodon->color())->toBe('#6364FF'); + expect(Platform::Vk->color())->toBe('#0077FF'); }); test('platform has correct allowed media types', function () { diff --git a/tests/Unit/Exceptions/Social/VkPublishExceptionTest.php b/tests/Unit/Exceptions/Social/VkPublishExceptionTest.php new file mode 100644 index 000000000..5a2c9eeab --- /dev/null +++ b/tests/Unit/Exceptions/Social/VkPublishExceptionTest.php @@ -0,0 +1,50 @@ + ['error_code' => $code, 'error_msg' => $msg]], $status); + + return Http::fake(['*' => $response])->post('https://vk.example/method/wall.post'); +} + +test('error 5 (authorization failed) throws TokenExpiredException', function () { + VkPublishException::fromApiResponse(fakeVkErrorResponse(5, 'User authorization failed.')); +})->throws(TokenExpiredException::class); + +test('error 6 (too many requests) maps to RateLimit category', function () { + $exception = VkPublishException::fromApiResponse(fakeVkErrorResponse(6, 'Too many requests per second.')); + + expect($exception->category)->toBe(ErrorCategory::RateLimit) + ->and($exception->platformErrorCode)->toBe('6'); +}); + +test('error 214 (post access denied) maps to Permission category', function () { + $exception = VkPublishException::fromApiResponse(fakeVkErrorResponse(214, 'Access to adding post denied.')); + + expect($exception->category)->toBe(ErrorCategory::Permission) + ->and($exception->userMessage)->toBe('Access to adding post denied.'); +}); + +test('unknown error code maps to Unknown category with vk platform', function () { + $exception = VkPublishException::fromApiResponse(fakeVkErrorResponse(1, 'Unknown error occurred.')); + + expect($exception->category)->toBe(ErrorCategory::Unknown) + ->and($exception->platform())->toBe('vk'); +}); + +test('transport 5xx without vk error object maps to ServerError', function () { + $response = Http::response('Bad gateway', 502); + $fakeResponse = Http::fake(['*' => $response])->post('https://vk.example/method/wall.post'); + + $exception = VkPublishException::fromApiResponse($fakeResponse); + + expect($exception->category)->toBe(ErrorCategory::ServerError) + ->and($exception->platformErrorCode)->toBe('502'); +});