Skip to content
7 changes: 7 additions & 0 deletions app/Enums/PostPlatform/ContentType.php
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ enum ContentType: string

// Google Business Profile
case GoogleBusinessPost = 'google_business_post';
// VK
case VkPost = 'vk_post';

/**
* AI generation format for an Instagram carousel. Not a content type —
Expand Down Expand Up @@ -88,6 +90,7 @@ public function label(): string
self::TelegramPost => 'Post',
self::DiscordMessage => 'Message',
self::GoogleBusinessPost => 'Post',
self::VkPost => 'Post',
};
}

Expand All @@ -113,6 +116,7 @@ public function platform(): SocialPlatform
self::TelegramPost => SocialPlatform::Telegram,
self::DiscordMessage => SocialPlatform::Discord,
self::GoogleBusinessPost => SocialPlatform::GoogleBusiness,
self::VkPost => SocialPlatform::Vk,
};
}

Expand Down Expand Up @@ -183,6 +187,7 @@ public function maxMediaCount(): int
self::TelegramPost => 10,
self::DiscordMessage => 10,
self::GoogleBusinessPost => 1,
self::VkPost => 10,
};
}

Expand Down Expand Up @@ -497,6 +502,7 @@ public function supportsVideo(): bool
self::TelegramPost => true,
self::DiscordMessage => true,
self::GoogleBusinessPost => false,
self::VkPost => true,
};
}

Expand Down Expand Up @@ -650,6 +656,7 @@ public static function defaultFor(SocialPlatform $platform): self
SocialPlatform::Telegram => self::TelegramPost,
SocialPlatform::Discord => self::DiscordMessage,
SocialPlatform::GoogleBusiness => self::GoogleBusinessPost,
SocialPlatform::Vk => self::VkPost,
};
}
}
13 changes: 13 additions & 0 deletions app/Enums/SocialAccount/Platform.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ enum Platform: string
case Telegram = 'telegram';
case Discord = 'discord';
case GoogleBusiness = 'google_business';
case Vk = 'vk';

public function network(): string
{
Expand Down Expand Up @@ -63,6 +64,7 @@ public function label(): string
self::Telegram => 'Telegram',
self::Discord => 'Discord',
self::GoogleBusiness => 'Google Business Profile',
self::Vk => 'VK',
};
}

Expand All @@ -83,6 +85,7 @@ public function color(): string
self::Telegram => '#26A5E4',
self::Discord => '#5865F2',
self::GoogleBusiness => '#4285F4',
self::Vk => '#0077FF',
};
}

Expand All @@ -102,6 +105,7 @@ public function allowedMediaTypes(): array
self::Telegram => [MediaType::Image, MediaType::Video],
self::Discord => [MediaType::Image, MediaType::Video],
self::GoogleBusiness => [MediaType::Image],
self::Vk => [MediaType::Image, MediaType::Video],
};
}

Expand All @@ -121,6 +125,7 @@ public function maxImages(): int
self::Telegram => 10,
self::Discord => 10,
self::GoogleBusiness => 1,
self::Vk => 10,
};
}

Expand All @@ -145,6 +150,7 @@ public function altTextMaxLength(): ?int
self::Pinterest => 500,
self::Discord => 1024,
self::TikTok, self::YouTube, self::Telegram, self::GoogleBusiness => null,
self::TikTok, self::YouTube, self::Telegram, self::Vk => null,
};
}

Expand Down Expand Up @@ -179,6 +185,7 @@ public function supportsAltText(): bool
* - Telegram: 4096 for a text message (media captions are capped at 1024,
* handled in the publisher by sending long text as its own message)
* - Google Business Profile Local Post `summary`: 1500
* - VK: 15895 characters for a wall post
*/
public function maxContentLength(): int
{
Expand All @@ -196,6 +203,7 @@ public function maxContentLength(): int
self::Telegram => 4096,
self::Discord => 2000,
self::GoogleBusiness => 1500,
self::Vk => 15895,
};
}

Expand Down Expand Up @@ -244,6 +252,8 @@ public function recommendedAiContentLength(): int
// Google Business Profile — image does most of the work, keep the
// summary tight and scannable
self::GoogleBusiness => 300,
// VK — feed favors short posts; long reads live in Articles
self::Vk => 400,
};
}

Expand All @@ -268,6 +278,7 @@ public function requiredPublishScopes(): array
self::Telegram => [],
self::Discord => [],
self::GoogleBusiness => ['https://www.googleapis.com/auth/business.manage'],
self::Vk => [],
};
}

Expand All @@ -287,6 +298,7 @@ public function supportsTextOnly(): bool
self::Telegram => true,
self::Discord => true,
self::GoogleBusiness => true,
self::Vk => true,
};
}

Expand Down Expand Up @@ -426,6 +438,7 @@ public function isEnabled(): bool
self::Telegram => 'TELEGRAM_ENABLED',
self::Discord => 'DISCORD_ENABLED',
self::GoogleBusiness => 'GOOGLE_BUSINESS_ENABLED',
self::Vk => 'VK_ENABLED',
}, true),
);
}
Expand Down
60 changes: 60 additions & 0 deletions app/Exceptions/Social/VkPublishException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
<?php

declare(strict_types=1);

namespace App\Exceptions\Social;

use App\Exceptions\TokenExpiredException;
use Illuminate\Http\Client\Response;

class VkPublishException extends SocialPublishException
{
public static function fromApiResponse(mixed $response): static
{
/** @var Response $response */
$rawResponse = $response->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;
}
}
3 changes: 3 additions & 0 deletions app/Http/Controllers/App/AnalyticsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
use App\Services\Social\LinkedInPageAnalytics;
use App\Services\Social\PinterestAnalytics;
use App\Services\Social\Telegram\TelegramAnalytics;
use App\Services\Social\Vk\VkAnalytics;
use App\Services\Social\ThreadsAnalytics;
use App\Services\Social\TikTokAnalytics;
use App\Services\Social\XAnalytics;
Expand All @@ -40,6 +41,7 @@ class AnalyticsController extends Controller
Platform::YouTube,
Platform::Telegram,
Platform::GoogleBusiness,
Platform::Vk,
];

public function index(Request $request): Response
Expand Down Expand Up @@ -125,6 +127,7 @@ private function metricsFor(SocialAccount $account, ?Carbon $since, ?Carbon $unt
Platform::YouTube => app(YouTubeAnalytics::class)->getMetrics($account, $since, $until),
Platform::Telegram => app(TelegramAnalytics::class)->getMetrics($account),
Platform::GoogleBusiness => app(GoogleBusinessAnalytics::class)->getMetrics($account, $since, $until),
Platform::Vk => app(VkAnalytics::class)->getMetrics($account),
default => [],
};
} catch (PlatformUnavailableException|ConnectionException $e) {
Expand Down
Loading
Loading