Every refresh*Token method inside ConnectionVerifier already finishes with \$account->update(...) (mutates the model) + \$account->refresh() (reloads from DB to pick up sibling updates like LinkedInTokenSynchronizer). The lock-contention branch in refreshToken() also calls \$account->refresh() before returning. So a second \$account->refresh() in the caller was always a redundant SELECT — no scenario where it actually pulled a different value than what CV already left in memory. Removed from all 15 call sites, plus fixed the duplicated "Mastodon tokens don't expire" comment.
333 lines
11 KiB
PHP
333 lines
11 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Services\Social;
|
|
|
|
use App\Enums\SocialAccount\Platform;
|
|
use App\Exceptions\Social\XPublishException;
|
|
use App\Models\PostPlatform;
|
|
use App\Services\Media\MediaOptimizer;
|
|
use App\Services\Social\Concerns\HasSocialHttpClient;
|
|
use Illuminate\Http\Client\PendingRequest;
|
|
use Illuminate\Http\Client\Response;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Illuminate\Support\Facades\Log;
|
|
|
|
class XPublisher
|
|
{
|
|
use HasSocialHttpClient;
|
|
|
|
private string $baseUrl;
|
|
|
|
private string $accessToken;
|
|
|
|
public function __construct()
|
|
{
|
|
$this->baseUrl = config('trypost.platforms.x.api');
|
|
}
|
|
|
|
public function publish(PostPlatform $postPlatform): array
|
|
{
|
|
$this->validateContentLength($postPlatform);
|
|
|
|
$content = $postPlatform->post->content ? app(ContentSanitizer::class)->sanitize($postPlatform->post->content, $postPlatform->platform) : null;
|
|
|
|
$account = $postPlatform->socialAccount;
|
|
|
|
// Refresh token if expired or expiring soon
|
|
if ($account->is_token_expired || $account->is_token_expiring_soon) {
|
|
app(ConnectionVerifier::class)->refreshToken($account);
|
|
}
|
|
|
|
$this->accessToken = $account->access_token;
|
|
|
|
$data = [];
|
|
|
|
if (! empty($content)) {
|
|
$data['text'] = $content;
|
|
}
|
|
|
|
$mediaIds = [];
|
|
$media = $postPlatform->post->mediaItems;
|
|
|
|
if ($media->isNotEmpty()) {
|
|
foreach ($media as $mediaItem) {
|
|
$uploadedMedia = $this->uploadMedia($mediaItem);
|
|
|
|
// v2 API returns data.id, v1 returns media_id
|
|
$mediaId = data_get($uploadedMedia, 'data.id', data_get($uploadedMedia, 'media_id'));
|
|
if ($mediaId) {
|
|
$mediaIds[] = $mediaId;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (! empty($mediaIds)) {
|
|
$data['media'] = [
|
|
'media_ids' => $mediaIds,
|
|
];
|
|
}
|
|
|
|
if (empty($content) && empty($mediaIds)) {
|
|
throw new \Exception('X posts require either text or media. Please add content to your post.');
|
|
}
|
|
|
|
$response = $this->getHttpClient()
|
|
->post("{$this->baseUrl}/tweets", $data);
|
|
|
|
if ($response->failed()) {
|
|
Log::error('X post creation failed', [
|
|
'status' => $response->status(),
|
|
'body' => $this->redactResponseBody($response->body()),
|
|
]);
|
|
$this->handleApiError($response);
|
|
}
|
|
|
|
$responseData = $response->json();
|
|
$tweetId = $responseData['data']['id'] ?? null;
|
|
|
|
return [
|
|
'id' => $tweetId ?? 'unknown',
|
|
'url' => $tweetId ? "https://x.com/{$account->username}/status/{$tweetId}" : null,
|
|
];
|
|
}
|
|
|
|
private function getHttpClient(): PendingRequest
|
|
{
|
|
return $this->socialHttp()->withToken($this->accessToken)
|
|
->withHeaders([
|
|
'Content-Type' => 'application/json',
|
|
'Accept' => 'application/json',
|
|
]);
|
|
}
|
|
|
|
private function uploadMedia($mediaItem): ?array
|
|
{
|
|
$mimeType = $mediaItem->mime_type;
|
|
|
|
// Download to temp file (memory-safe)
|
|
$tempFile = tempnam(sys_get_temp_dir(), 'x_media_');
|
|
|
|
try {
|
|
$downloadResponse = Http::withOptions(['sink' => $tempFile])->timeout(600)->get($mediaItem->url);
|
|
|
|
if ($downloadResponse->failed()) {
|
|
throw new \Exception('Failed to download media: HTTP '.$downloadResponse->status());
|
|
}
|
|
|
|
// Optimize images (skip GIFs — they need special handling)
|
|
if (str_starts_with($mimeType, 'image/') && ! str_starts_with($mimeType, 'image/gif')) {
|
|
$optimizer = app(MediaOptimizer::class);
|
|
$optimizedPath = $optimizer->optimizeImage($tempFile, Platform::X);
|
|
@unlink($tempFile);
|
|
$tempFile = $optimizedPath;
|
|
$mimeType = 'image/jpeg';
|
|
}
|
|
|
|
$fileSize = filesize($tempFile);
|
|
$mediaCategory = $this->getMediaCategory($mimeType, $fileSize);
|
|
|
|
$isVideo = str_starts_with($mimeType, 'video/');
|
|
$isGif = $mimeType === 'image/gif';
|
|
|
|
$useChunkedUpload = $isVideo || $isGif || $fileSize > 5 * 1024 * 1024;
|
|
|
|
if ($useChunkedUpload) {
|
|
return $this->chunkedUpload($tempFile, $fileSize, $mimeType, $mediaCategory);
|
|
}
|
|
|
|
// Simple upload for small images
|
|
$response = $this->socialHttp()->withToken($this->accessToken)
|
|
->timeout(360)
|
|
->attach(
|
|
'media',
|
|
fopen($tempFile, 'r'),
|
|
basename($tempFile),
|
|
['Content-Type' => $mimeType]
|
|
);
|
|
|
|
$formParams = [];
|
|
if ($mediaCategory) {
|
|
$formParams['media_category'] = $mediaCategory;
|
|
}
|
|
|
|
$response = $response->post("{$this->baseUrl}/media/upload", $formParams);
|
|
|
|
if ($response->failed()) {
|
|
Log::error('X media upload error', [
|
|
'status' => $response->status(),
|
|
'body' => $this->redactResponseBody($response->body()),
|
|
]);
|
|
$this->handleApiError($response);
|
|
}
|
|
|
|
$responseData = $response->json();
|
|
|
|
$mediaId = $responseData['data']['id'] ?? $responseData['media_id'] ?? null;
|
|
|
|
if ($isGif && $mediaId) {
|
|
$this->waitForProcessing($mediaId);
|
|
}
|
|
|
|
return $responseData;
|
|
} finally {
|
|
@unlink($tempFile);
|
|
}
|
|
}
|
|
|
|
private function chunkedUpload(string $tempFile, int $totalBytes, string $mimeType, string $mediaCategory): array
|
|
{
|
|
// INIT
|
|
$initResponse = $this->socialHttp()->withToken($this->accessToken)
|
|
->timeout(60)
|
|
->post("{$this->baseUrl}/media/upload/initialize", [
|
|
'media_type' => $mimeType,
|
|
'media_category' => $mediaCategory,
|
|
'total_bytes' => $totalBytes,
|
|
]);
|
|
|
|
if ($initResponse->failed()) {
|
|
Log::error('X chunked upload INIT error', [
|
|
'status' => $initResponse->status(),
|
|
'body' => $this->redactResponseBody($initResponse->body()),
|
|
]);
|
|
$this->handleApiError($initResponse);
|
|
}
|
|
|
|
$initData = $initResponse->json();
|
|
$mediaId = $initData['data']['id'] ?? $initData['media_id'] ?? null;
|
|
|
|
if (! $mediaId) {
|
|
throw new \Exception('No media_id returned from INIT');
|
|
}
|
|
|
|
// APPEND - Read from temp file in 1MB chunks. Matches the
|
|
// twitter-api-v2 SDK default and X's own quickstart examples;
|
|
// larger chunks (we previously used 5MB) trigger 413 at the X
|
|
// edge with an empty body, surfacing as "An unknown X error".
|
|
$chunkSize = 1024 * 1024;
|
|
$handle = fopen($tempFile, 'r');
|
|
$index = 0;
|
|
|
|
try {
|
|
while (! feof($handle)) {
|
|
$chunk = fread($handle, $chunkSize);
|
|
|
|
if ($chunk === '' || $chunk === false) {
|
|
break;
|
|
}
|
|
|
|
$appendResponse = $this->socialHttp()->withToken($this->accessToken)
|
|
->timeout(300)
|
|
->attach('media', $chunk, 'chunk'.$index, ['Content-Type' => $mimeType])
|
|
->post("{$this->baseUrl}/media/upload/{$mediaId}/append", [
|
|
'segment_index' => $index,
|
|
]);
|
|
|
|
if ($appendResponse->failed()) {
|
|
Log::error('X chunked upload APPEND error', [
|
|
'status' => $appendResponse->status(),
|
|
'body' => $this->redactResponseBody($appendResponse->body()),
|
|
'segment' => $index,
|
|
]);
|
|
$this->handleApiError($appendResponse);
|
|
}
|
|
|
|
$index++;
|
|
}
|
|
} finally {
|
|
fclose($handle);
|
|
}
|
|
|
|
// FINALIZE - Use the new v2 endpoint
|
|
$finalizeResponse = $this->socialHttp()->withToken($this->accessToken)
|
|
->timeout(60)
|
|
->post("{$this->baseUrl}/media/upload/{$mediaId}/finalize");
|
|
|
|
if ($finalizeResponse->failed()) {
|
|
Log::error('X chunked upload FINALIZE error', [
|
|
'status' => $finalizeResponse->status(),
|
|
'body' => $this->redactResponseBody($finalizeResponse->body()),
|
|
]);
|
|
$this->handleApiError($finalizeResponse);
|
|
}
|
|
|
|
$finalizeData = $finalizeResponse->json();
|
|
|
|
// Wait for processing (videos need transcoding)
|
|
if (isset($finalizeData['processing_info']) || str_starts_with($mimeType, 'video/')) {
|
|
$this->waitForProcessing($mediaId);
|
|
}
|
|
|
|
// Return in same format as simple upload
|
|
return [
|
|
'data' => [
|
|
'id' => $mediaId,
|
|
],
|
|
];
|
|
}
|
|
|
|
private function getMediaCategory(string $mimeType, int $fileSize): ?string
|
|
{
|
|
if (str_starts_with($mimeType, 'video/')) {
|
|
return $fileSize > 15 * 1024 * 1024 ? 'amplify_video' : 'tweet_video';
|
|
}
|
|
|
|
if ($mimeType === 'image/gif') {
|
|
return 'tweet_gif';
|
|
}
|
|
|
|
if (str_starts_with($mimeType, 'image/')) {
|
|
return 'tweet_image';
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private function waitForProcessing(string $mediaId, int $maxAttempts = 20): bool
|
|
{
|
|
for ($i = 0; $i < $maxAttempts; $i++) {
|
|
$response = $this->getHttpClient()
|
|
->get("{$this->baseUrl}/media/{$mediaId}");
|
|
|
|
if ($response->failed()) {
|
|
Log::error('X media status check error', ['body' => $this->redactResponseBody($response->body())]);
|
|
sleep(3);
|
|
|
|
continue;
|
|
}
|
|
|
|
$responseData = $response->json();
|
|
|
|
// If processing_info doesn't exist, assume it's ready
|
|
if (! isset($responseData['processing_info'])) {
|
|
return true;
|
|
}
|
|
|
|
$state = $responseData['processing_info']['state'] ?? 'unknown';
|
|
|
|
if ($state === 'succeeded') {
|
|
return true;
|
|
}
|
|
|
|
if ($state === 'failed') {
|
|
$error = $responseData['processing_info']['error'] ?? 'Unknown error';
|
|
Log::error('X media processing failed: '.$error);
|
|
|
|
return false;
|
|
}
|
|
|
|
// Wait before checking again
|
|
$waitTime = $responseData['processing_info']['check_after_secs'] ?? 3;
|
|
sleep($waitTime);
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private function handleApiError(Response $response): never
|
|
{
|
|
throw XPublishException::fromApiResponse($response);
|
|
}
|
|
}
|