diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..e9826d2 --- /dev/null +++ b/composer.json @@ -0,0 +1,17 @@ +{ + "name": "core/php-plug-social", + "description": "Social media provider integrations for the Plug framework", + "type": "library", + "license": "EUPL-1.2", + "require": { + "php": "^8.2", + "core/php": "^1.0" + }, + "autoload": { + "psr-4": { + "Core\\Plug\\Social\\": "src/" + } + }, + "minimum-stability": "dev", + "prefer-stable": true +} diff --git a/src/LinkedIn/Auth.php b/src/LinkedIn/Auth.php new file mode 100644 index 0000000..6097c3c --- /dev/null +++ b/src/LinkedIn/Auth.php @@ -0,0 +1,147 @@ +setScope(); + } + + public static function identifier(): string + { + return 'linkedin'; + } + + public static function name(): string + { + return 'LinkedIn'; + } + + protected function setScope(): void + { + $product = config('social.providers.linkedin.product', 'community_management'); + + $this->scope = match ($product) { + 'sign_share' => ['r_liteprofile', 'r_emailaddress', 'w_member_social'], + 'sign_open_id_share' => ['openid', 'profile', 'w_member_social'], + 'community_management' => [ + 'w_member_social', + 'w_member_social_feed', + 'r_basicprofile', + 'r_organization_social', + 'r_organization_social_feed', + 'w_organization_social', + 'w_organization_social_feed', + 'rw_organization_admin', + ], + default => [] + }; + } + + protected function httpHeaders(): array + { + return [ + 'X-Restli-Protocol-Version' => '2.0.0', + 'LinkedIn-Version' => '202507', + ]; + } + + public function getAuthUrl(): string + { + $params = [ + 'client_id' => $this->clientId, + 'redirect_uri' => $this->redirectUrl, + 'scope' => urlencode(implode(' ', $this->scope)), + 'state' => $this->values['state'] ?? '', + 'response_type' => 'code', + ]; + + $url = self::OAUTH_URL."/{$this->apiVersion}/authorization"; + + return str_replace('%2B', '%20', $this->buildUrl($url, $params)); + } + + public function requestAccessToken(array $params = []): array + { + $response = $this->http() + ->withHeaders($this->httpHeaders()) + ->asForm() + ->post(self::OAUTH_URL."/{$this->apiVersion}/accessToken", [ + 'grant_type' => 'authorization_code', + 'code' => $params['code'], + 'client_id' => $this->clientId, + 'client_secret' => $this->clientSecret, + 'redirect_uri' => $this->redirectUrl, + ]) + ->json(); + + if (isset($response['serviceErrorCode']) || isset($response['error'])) { + return [ + 'error' => $response['message'] ?? $response['error_description'] ?? 'Unknown error', + ]; + } + + return [ + 'access_token' => $response['access_token'], + 'expires_in' => Carbon::now('UTC')->addSeconds($response['expires_in'])->timestamp, + 'refresh_token' => $response['refresh_token'] ?? '', + ]; + } + + public function refresh(): Response + { + $response = $this->http() + ->withHeaders($this->httpHeaders()) + ->asForm() + ->post(self::OAUTH_URL."/{$this->apiVersion}/accessToken", [ + 'grant_type' => 'refresh_token', + 'refresh_token' => $this->getToken()['refresh_token'] ?? '', + 'client_id' => $this->clientId, + 'client_secret' => $this->clientSecret, + ]); + + return $this->fromHttp($response, fn ($data) => [ + 'access_token' => $data['access_token'], + 'expires_in' => Carbon::now('UTC')->addSeconds($data['expires_in'])->timestamp, + 'refresh_token' => $data['refresh_token'] ?? '', + ]); + } + + public function getAccount(): Response + { + return $this->error('Use Read class with token for account info'); + } +} diff --git a/src/LinkedIn/Delete.php b/src/LinkedIn/Delete.php new file mode 100644 index 0000000..6c21a30 --- /dev/null +++ b/src/LinkedIn/Delete.php @@ -0,0 +1,45 @@ + '2.0.0', + 'LinkedIn-Version' => '202507', + ]; + } + + public function delete(string $id): Response + { + $response = $this->http() + ->withToken($this->accessToken()) + ->withHeaders($this->httpHeaders()) + ->delete(self::API_URL."/{$this->apiVersion}/ugcPosts/{$id}"); + + return $this->fromHttp($response, fn () => [ + 'deleted' => true, + ]); + } +} diff --git a/src/LinkedIn/Media.php b/src/LinkedIn/Media.php new file mode 100644 index 0000000..00f168f --- /dev/null +++ b/src/LinkedIn/Media.php @@ -0,0 +1,101 @@ + '2.0.0', + 'LinkedIn-Version' => '202507', + ]; + } + + /** + * Set the author URN for uploads. + */ + public function forAuthor(string $authorUrn): static + { + $this->authorUrn = $authorUrn; + + return $this; + } + + public function upload(array $item): Response + { + if (! $this->authorUrn) { + return $this->error('Author URN required. Call forAuthor() first.'); + } + + // Register upload + $registerResponse = $this->http() + ->withToken($this->accessToken()) + ->withHeaders($this->httpHeaders()) + ->post(self::API_URL."/{$this->apiVersion}/assets?action=registerUpload", [ + 'registerUploadRequest' => [ + 'recipes' => ['urn:li:digitalmediaRecipe:feedshare-image'], + 'owner' => $this->authorUrn, + 'serviceRelationships' => [ + [ + 'relationshipType' => 'OWNER', + 'identifier' => 'urn:li:userGeneratedContent', + ], + ], + ], + ]); + + if (! $registerResponse->successful()) { + return $this->error('Failed to register media upload'); + } + + $registerData = $registerResponse->json(); + $uploadUrl = $registerData['value']['uploadMechanism']['com.linkedin.digitalmedia.uploading.MediaUploadHttpRequest']['uploadUrl'] ?? null; + $asset = $registerData['value']['asset'] ?? null; + + if (! $uploadUrl || ! $asset) { + return $this->error('Failed to get upload URL'); + } + + // Upload the file + $uploadResponse = $this->http() + ->withToken($this->accessToken()) + ->attach('file', file_get_contents($item['path']), $item['name'] ?? 'image') + ->put($uploadUrl); + + if (! $uploadResponse->successful()) { + return $this->error('Failed to upload media'); + } + + return $this->ok([ + 'status' => 'READY', + 'media' => $asset, + 'title' => [ + 'text' => $item['alt_text'] ?? '', + ], + ]); + } +} diff --git a/src/LinkedIn/Pages.php b/src/LinkedIn/Pages.php new file mode 100644 index 0000000..9b354d0 --- /dev/null +++ b/src/LinkedIn/Pages.php @@ -0,0 +1,69 @@ + '2.0.0', + 'LinkedIn-Version' => '202507', + ]; + } + + public function listEntities(array $params = []): Response + { + $response = $this->http() + ->withToken($this->accessToken()) + ->withHeaders($this->httpHeaders()) + ->get(self::API_URL."/{$this->apiVersion}/organizationAcls", [ + 'q' => 'roleAssignee', + 'projection' => '(elements*(organization~(id,localizedName,vanityName,logoV2(cropped~:playableStreams))))', + ]); + + return $this->fromHttp($response, function ($data) { + $pages = []; + + foreach ($data['elements'] ?? [] as $element) { + $org = $element['organization~'] ?? null; + if (! $org) { + continue; + } + + $logo = null; + if (isset($org['logoV2']['cropped~']['elements'][0]['identifiers'][0]['identifier'])) { + $logo = $org['logoV2']['cropped~']['elements'][0]['identifiers'][0]['identifier']; + } + + $pages[] = [ + 'id' => $org['id'], + 'name' => $org['localizedName'] ?? '', + 'username' => $org['vanityName'] ?? '', + 'image' => $logo, + ]; + } + + return $pages; + }); + } +} diff --git a/src/LinkedIn/Post.php b/src/LinkedIn/Post.php new file mode 100644 index 0000000..ec286d7 --- /dev/null +++ b/src/LinkedIn/Post.php @@ -0,0 +1,99 @@ + '2.0.0', + 'LinkedIn-Version' => '202507', + ]; + } + + public function publish(string $text, Collection $media, array $params = []): Response + { + $token = $this->getToken(); + $authorUrn = $params['author_urn'] ?? "urn:li:person:{$token['id']}"; + + $post = [ + 'author' => $authorUrn, + 'lifecycleState' => 'PUBLISHED', + 'specificContent' => [ + 'com.linkedin.ugc.ShareContent' => [ + 'shareCommentary' => [ + 'text' => $text, + ], + 'shareMediaCategory' => 'NONE', + ], + ], + 'visibility' => [ + 'com.linkedin.ugc.MemberNetworkVisibility' => 'PUBLIC', + ], + ]; + + if ($media->isNotEmpty()) { + $uploadedMedia = []; + $mediaUploader = (new Media)->withToken($token)->forAuthor($authorUrn); + + foreach ($media as $item) { + $result = $mediaUploader->upload($item); + + if ($result->hasError()) { + return $result; + } + + $uploadedMedia[] = $result->context(); + } + + $post['specificContent']['com.linkedin.ugc.ShareContent']['shareMediaCategory'] = 'IMAGE'; + $post['specificContent']['com.linkedin.ugc.ShareContent']['media'] = $uploadedMedia; + } + + $response = $this->http() + ->withToken($this->accessToken()) + ->withHeaders($this->httpHeaders()) + ->post(self::API_URL."/{$this->apiVersion}/ugcPosts", $post); + + return $this->fromHttp($response, fn ($data) => [ + 'id' => $data['id'] ?? '', + ]); + } + + /** + * Get external URL to a LinkedIn post. + */ + public static function externalPostUrl(string $postId): string + { + return "https://linkedin.com/feed/update/{$postId}"; + } + + /** + * Get external URL to a LinkedIn profile. + */ + public static function externalAccountUrl(string $username): string + { + return "https://www.linkedin.com/in/{$username}"; + } +} diff --git a/src/LinkedIn/Read.php b/src/LinkedIn/Read.php new file mode 100644 index 0000000..73d3323 --- /dev/null +++ b/src/LinkedIn/Read.php @@ -0,0 +1,62 @@ + '2.0.0', + 'LinkedIn-Version' => '202507', + ]; + } + + public function get(string $id): Response + { + $response = $this->http() + ->withToken($this->accessToken()) + ->withHeaders($this->httpHeaders()) + ->get(self::API_URL."/{$this->apiVersion}/userinfo"); + + return $this->fromHttp($response, fn ($data) => [ + 'id' => $data['sub'], + 'name' => $data['name'] ?? trim(($data['given_name'] ?? '').' '.($data['family_name'] ?? '')), + 'username' => $data['email'] ?? '', + 'image' => $data['picture'] ?? null, + ]); + } + + /** + * Get the authenticated user's account info. + */ + public function me(): Response + { + return $this->get('me'); + } + + public function list(array $params = []): Response + { + // LinkedIn doesn't have a simple feed API + return $this->error('Post listing not supported via API'); + } +} diff --git a/src/Meta/Auth.php b/src/Meta/Auth.php new file mode 100644 index 0000000..0fde38e --- /dev/null +++ b/src/Meta/Auth.php @@ -0,0 +1,112 @@ +apiVersion = config('social.providers.meta.api_version', 'v18.0'); + } + + public static function identifier(): string + { + return 'meta'; + } + + public static function name(): string + { + return 'Meta'; + } + + public function getAuthUrl(): string + { + $params = [ + 'client_id' => $this->clientId, + 'redirect_uri' => $this->redirectUrl, + 'scope' => implode(',', $this->scopes), + 'state' => $this->values['state'] ?? '', + 'response_type' => 'code', + ]; + + return $this->buildUrl(self::API_URL."/{$this->apiVersion}/dialog/oauth", $params); + } + + public function requestAccessToken(array $params = []): array + { + $response = $this->http() + ->post(self::API_URL."/{$this->apiVersion}/oauth/access_token", [ + 'client_id' => $this->clientId, + 'client_secret' => $this->clientSecret, + 'redirect_uri' => $this->redirectUrl, + 'code' => $params['code'], + ]) + ->json(); + + if (isset($response['error'])) { + return [ + 'error' => $response['error']['message'] ?? 'Unknown error', + ]; + } + + // Exchange for long-lived token + $longLivedResponse = $this->http() + ->get(self::API_URL."/{$this->apiVersion}/oauth/access_token", [ + 'grant_type' => 'fb_exchange_token', + 'client_id' => $this->clientId, + 'client_secret' => $this->clientSecret, + 'fb_exchange_token' => $response['access_token'], + ]) + ->json(); + + $expiresIn = $longLivedResponse['expires_in'] ?? 5184000; // 60 days default + + return [ + 'access_token' => $longLivedResponse['access_token'] ?? $response['access_token'], + 'expires_in' => Carbon::now('UTC')->addSeconds($expiresIn)->timestamp, + 'token_type' => $longLivedResponse['token_type'] ?? 'bearer', + ]; + } + + public function getAccount(): Response + { + return $this->error('Use Read class with token for account info'); + } +} diff --git a/src/Meta/Delete.php b/src/Meta/Delete.php new file mode 100644 index 0000000..f2113ee --- /dev/null +++ b/src/Meta/Delete.php @@ -0,0 +1,42 @@ +apiVersion = config('social.providers.meta.api_version', 'v18.0'); + } + + public function delete(string $id): Response + { + $response = $this->http() + ->delete(self::API_URL."/{$this->apiVersion}/{$id}", [ + 'access_token' => $this->accessToken(), + ]); + + return $this->fromHttp($response, fn ($data) => [ + 'deleted' => $data['success'] ?? true, + ]); + } +} diff --git a/src/Meta/Media.php b/src/Meta/Media.php new file mode 100644 index 0000000..e6fe02a --- /dev/null +++ b/src/Meta/Media.php @@ -0,0 +1,64 @@ +apiVersion = config('social.providers.meta.api_version', 'v18.0'); + } + + /** + * Set the page context for uploads. + */ + public function forPage(string $pageId, string $pageToken): static + { + $this->pageId = $pageId; + $this->pageToken = $pageToken; + + return $this; + } + + public function upload(array $item): Response + { + if (! $this->pageId || ! $this->pageToken) { + return $this->error('Page ID and token required. Call forPage() first.'); + } + + $response = $this->http() + ->attach('source', file_get_contents($item['path']), $item['name'] ?? 'photo.jpg') + ->post(self::API_URL."/{$this->apiVersion}/{$this->pageId}/photos", [ + 'access_token' => $this->pageToken, + 'caption' => $item['alt_text'] ?? '', + 'published' => $item['published'] ?? false, // Unpublished for multi-photo posts + ]); + + return $this->fromHttp($response, fn ($data) => [ + 'id' => $data['id'], + ]); + } +} diff --git a/src/Meta/Pages.php b/src/Meta/Pages.php new file mode 100644 index 0000000..c61efca --- /dev/null +++ b/src/Meta/Pages.php @@ -0,0 +1,72 @@ +apiVersion = config('social.providers.meta.api_version', 'v18.0'); + } + + public function listEntities(array $params = []): Response + { + $response = $this->http() + ->get(self::API_URL."/{$this->apiVersion}/me/accounts", [ + 'access_token' => $this->accessToken(), + 'fields' => 'id,name,access_token,picture{url},instagram_business_account{id,name,username,profile_picture_url}', + ]); + + return $this->fromHttp($response, function ($data) { + $pages = []; + + foreach ($data['data'] ?? [] as $page) { + // Facebook Page + $pages[] = [ + 'id' => $page['id'], + 'name' => $page['name'], + 'username' => '', + 'image' => $page['picture']['data']['url'] ?? null, + 'access_token' => $page['access_token'], + 'type' => 'facebook_page', + ]; + + // Instagram Business Account (if connected) + if (isset($page['instagram_business_account'])) { + $ig = $page['instagram_business_account']; + $pages[] = [ + 'id' => $ig['id'], + 'name' => $ig['name'] ?? $ig['username'], + 'username' => $ig['username'] ?? '', + 'image' => $ig['profile_picture_url'] ?? null, + 'page_access_token' => $page['access_token'], + 'type' => 'instagram', + ]; + } + } + + return $pages; + }); + } +} diff --git a/src/Meta/Post.php b/src/Meta/Post.php new file mode 100644 index 0000000..7deefca --- /dev/null +++ b/src/Meta/Post.php @@ -0,0 +1,102 @@ +apiVersion = config('social.providers.meta.api_version', 'v18.0'); + } + + public function publish(string $text, Collection $media, array $params = []): Response + { + $pageId = $params['page_id'] ?? null; + $pageToken = $params['page_access_token'] ?? $this->accessToken(); + + if (! $pageId) { + return $this->error('Page ID is required'); + } + + $postData = [ + 'access_token' => $pageToken, + 'message' => $text, + ]; + + // Handle media attachments + if ($media->isNotEmpty()) { + $mediaIds = []; + $mediaUploader = (new Media)->forPage($pageId, $pageToken); + + foreach ($media as $item) { + $result = $mediaUploader->upload($item); + + if ($result->hasError()) { + return $result; + } + + $mediaIds[] = $result->get('id'); + } + + // For multiple photos, use attached_media + if (count($mediaIds) > 1) { + $postData['attached_media'] = array_map( + fn ($id) => ['media_fbid' => $id], + $mediaIds + ); + } else { + // Single photo was already published with the upload + return $this->ok(['id' => $mediaIds[0]]); + } + } + + $response = $this->http() + ->post(self::API_URL."/{$this->apiVersion}/{$pageId}/feed", $postData); + + return $this->fromHttp($response, fn ($data) => [ + 'id' => $data['id'], + ]); + } + + /** + * Get external URL to a Facebook post. + */ + public static function externalPostUrl(string $postId): string + { + // Post ID format is usually "pageId_postId" + $parts = explode('_', $postId); + if (count($parts) === 2) { + return "https://www.facebook.com/{$parts[0]}/posts/{$parts[1]}"; + } + + return "https://www.facebook.com/{$postId}"; + } + + /** + * Get external URL to a Facebook page. + */ + public static function externalAccountUrl(string $pageId): string + { + return "https://www.facebook.com/{$pageId}"; + } +} diff --git a/src/Meta/Read.php b/src/Meta/Read.php new file mode 100644 index 0000000..ec01394 --- /dev/null +++ b/src/Meta/Read.php @@ -0,0 +1,76 @@ +apiVersion = config('social.providers.meta.api_version', 'v18.0'); + } + + public function get(string $id): Response + { + $response = $this->http() + ->get(self::API_URL."/{$this->apiVersion}/{$id}", [ + 'access_token' => $this->accessToken(), + 'fields' => 'id,name,email', + ]); + + return $this->fromHttp($response, fn ($data) => [ + 'id' => $data['id'], + 'name' => $data['name'], + 'username' => $data['email'] ?? '', + 'image' => self::API_URL."/{$this->apiVersion}/{$data['id']}/picture?type=large", + ]); + } + + /** + * Get the authenticated user's account info. + */ + public function me(): Response + { + return $this->get('me'); + } + + public function list(array $params = []): Response + { + // For listing posts, use the page ID + $pageId = $params['page_id'] ?? null; + + if (! $pageId) { + return $this->error('page_id is required'); + } + + $response = $this->http() + ->get(self::API_URL."/{$this->apiVersion}/{$pageId}/feed", [ + 'access_token' => $this->accessToken(), + 'fields' => 'id,message,created_time,full_picture', + 'limit' => $params['limit'] ?? 10, + ]); + + return $this->fromHttp($response, fn ($data) => [ + 'posts' => $data['data'] ?? [], + 'paging' => $data['paging'] ?? [], + ]); + } +} diff --git a/src/Pinterest/Auth.php b/src/Pinterest/Auth.php new file mode 100644 index 0000000..d876080 --- /dev/null +++ b/src/Pinterest/Auth.php @@ -0,0 +1,108 @@ + $this->clientId, + 'redirect_uri' => $this->redirectUrl, + 'scope' => implode(',', $this->scope), + 'state' => $this->values['state'] ?? '', + 'response_type' => 'code', + ]; + + return $this->buildUrl(self::AUTH_URL, $params); + } + + public function requestAccessToken(array $params = []): array + { + $response = $this->http() + ->withBasicAuth($this->clientId, $this->clientSecret) + ->asForm() + ->post(self::API_URL.'/oauth/token', [ + 'grant_type' => 'authorization_code', + 'code' => $params['code'], + 'redirect_uri' => $this->redirectUrl, + ]) + ->json(); + + if (isset($response['error'])) { + return [ + 'error' => $response['error_description'] ?? $response['message'] ?? 'Unknown error', + ]; + } + + return [ + 'access_token' => $response['access_token'], + 'refresh_token' => $response['refresh_token'] ?? '', + 'expires_in' => Carbon::now('UTC')->addSeconds($response['expires_in'] ?? 86400)->timestamp, + ]; + } + + public function refresh(): Response + { + $response = $this->http() + ->withBasicAuth($this->clientId, $this->clientSecret) + ->asForm() + ->post(self::API_URL.'/oauth/token', [ + 'grant_type' => 'refresh_token', + 'refresh_token' => $this->getToken()['refresh_token'] ?? '', + ]); + + return $this->fromHttp($response, fn ($data) => [ + 'access_token' => $data['access_token'], + 'refresh_token' => $data['refresh_token'] ?? '', + 'expires_in' => Carbon::now('UTC')->addSeconds($data['expires_in'] ?? 86400)->timestamp, + ]); + } + + public function getAccount(): Response + { + return $this->error('Use Read class with token for account info'); + } +} diff --git a/src/Pinterest/Boards.php b/src/Pinterest/Boards.php new file mode 100644 index 0000000..afb3302 --- /dev/null +++ b/src/Pinterest/Boards.php @@ -0,0 +1,47 @@ +http() + ->withToken($this->accessToken()) + ->get(self::API_URL.'/boards', [ + 'page_size' => $params['limit'] ?? 25, + ]); + + return $this->fromHttp($response, function ($data) { + $boards = []; + + foreach ($data['items'] ?? [] as $board) { + $boards[] = [ + 'id' => $board['id'], + 'name' => $board['name'], + 'description' => $board['description'] ?? '', + 'privacy' => $board['privacy'] ?? 'PUBLIC', + ]; + } + + return $boards; + }); + } +} diff --git a/src/Pinterest/Delete.php b/src/Pinterest/Delete.php new file mode 100644 index 0000000..d664f43 --- /dev/null +++ b/src/Pinterest/Delete.php @@ -0,0 +1,34 @@ +http() + ->withToken($this->accessToken()) + ->delete(self::API_URL."/pins/{$id}"); + + return $this->fromHttp($response, fn () => [ + 'deleted' => true, + ]); + } +} diff --git a/src/Pinterest/Media.php b/src/Pinterest/Media.php new file mode 100644 index 0000000..3c8aa4e --- /dev/null +++ b/src/Pinterest/Media.php @@ -0,0 +1,35 @@ +http() + ->withToken($this->accessToken()) + ->attach('file', file_get_contents($item['path']), $item['name'] ?? 'image.jpg') + ->post(self::API_URL.'/media'); + + return $this->fromHttp($response, fn ($data) => [ + 'media_id' => $data['media_id'], + ]); + } +} diff --git a/src/Pinterest/Post.php b/src/Pinterest/Post.php new file mode 100644 index 0000000..6623ef4 --- /dev/null +++ b/src/Pinterest/Post.php @@ -0,0 +1,88 @@ +isEmpty()) { + return $this->error('Pinterest requires an image'); + } + + $boardId = $params['board_id'] ?? null; + + if (! $boardId) { + return $this->error('Board ID is required'); + } + + $image = $media->first(); + + // Upload media first + $mediaUploader = (new Media)->withToken($this->getToken()); + $mediaResult = $mediaUploader->upload($image); + + if ($mediaResult->hasError()) { + return $mediaResult; + } + + $mediaId = $mediaResult->get('media_id'); + + // Create pin + $pinData = [ + 'board_id' => $boardId, + 'title' => $params['title'] ?? '', + 'description' => $text, + 'media_source' => [ + 'source_type' => 'media_id', + 'media_id' => $mediaId, + ], + ]; + + if (isset($params['link'])) { + $pinData['link'] = $params['link']; + } + + $response = $this->http() + ->withToken($this->accessToken()) + ->post(self::API_URL.'/pins', $pinData); + + return $this->fromHttp($response, fn ($data) => [ + 'id' => $data['id'], + ]); + } + + /** + * Get external URL to a Pinterest pin. + */ + public static function externalPostUrl(string $pinId): string + { + return "https://www.pinterest.com/pin/{$pinId}"; + } + + /** + * Get external URL to a Pinterest profile. + */ + public static function externalAccountUrl(string $username): string + { + return "https://www.pinterest.com/{$username}"; + } +} diff --git a/src/Pinterest/Read.php b/src/Pinterest/Read.php new file mode 100644 index 0000000..080822a --- /dev/null +++ b/src/Pinterest/Read.php @@ -0,0 +1,75 @@ +http() + ->withToken($this->accessToken()) + ->get(self::API_URL."/pins/{$id}"); + + return $this->fromHttp($response, fn ($data) => [ + 'id' => $data['id'] ?? '', + 'title' => $data['title'] ?? '', + 'description' => $data['description'] ?? '', + 'link' => $data['link'] ?? null, + 'board_id' => $data['board_id'] ?? null, + ]); + } + + /** + * Get the authenticated user's account info. + */ + public function me(): Response + { + $response = $this->http() + ->withToken($this->accessToken()) + ->get(self::API_URL.'/user_account'); + + return $this->fromHttp($response, fn ($data) => [ + 'id' => $data['id'] ?? '', + 'name' => $data['business_name'] ?? $data['username'] ?? '', + 'username' => $data['username'] ?? '', + 'image' => $data['profile_image'] ?? null, + ]); + } + + public function list(array $params = []): Response + { + $boardId = $params['board_id'] ?? null; + + if (! $boardId) { + return $this->error('board_id is required'); + } + + $response = $this->http() + ->withToken($this->accessToken()) + ->get(self::API_URL."/boards/{$boardId}/pins", [ + 'page_size' => $params['limit'] ?? 25, + ]); + + return $this->fromHttp($response, fn ($data) => [ + 'pins' => $data['items'] ?? [], + 'bookmark' => $data['bookmark'] ?? null, + ]); + } +} diff --git a/src/Reddit/Auth.php b/src/Reddit/Auth.php new file mode 100644 index 0000000..f1623c8 --- /dev/null +++ b/src/Reddit/Auth.php @@ -0,0 +1,108 @@ + $this->clientId, + 'redirect_uri' => $this->redirectUrl, + 'scope' => implode(' ', $this->scope), + 'state' => $this->values['state'] ?? '', + 'response_type' => 'code', + 'duration' => 'permanent', + ]; + + return $this->buildUrl(self::AUTH_URL.'/authorize', $params); + } + + public function requestAccessToken(array $params = []): array + { + $response = $this->http() + ->withBasicAuth($this->clientId, $this->clientSecret) + ->asForm() + ->post(self::AUTH_URL.'/access_token', [ + 'grant_type' => 'authorization_code', + 'code' => $params['code'], + 'redirect_uri' => $this->redirectUrl, + ]) + ->json(); + + if (isset($response['error'])) { + return [ + 'error' => $response['error_description'] ?? $response['error'], + ]; + } + + return [ + 'access_token' => $response['access_token'], + 'refresh_token' => $response['refresh_token'] ?? '', + 'expires_in' => Carbon::now('UTC')->addSeconds($response['expires_in'] ?? 3600)->timestamp, + 'scope' => $response['scope'] ?? '', + ]; + } + + public function refresh(): Response + { + $response = $this->http() + ->withBasicAuth($this->clientId, $this->clientSecret) + ->asForm() + ->post(self::AUTH_URL.'/access_token', [ + 'grant_type' => 'refresh_token', + 'refresh_token' => $this->getToken()['refresh_token'] ?? '', + ]); + + return $this->fromHttp($response, fn ($data) => [ + 'access_token' => $data['access_token'], + 'refresh_token' => $data['refresh_token'] ?? $this->getToken()['refresh_token'] ?? '', + 'expires_in' => Carbon::now('UTC')->addSeconds($data['expires_in'] ?? 3600)->timestamp, + ]); + } + + public function getAccount(): Response + { + return $this->error('Use Read class with token for account info'); + } +} diff --git a/src/Reddit/Delete.php b/src/Reddit/Delete.php new file mode 100644 index 0000000..ab84216 --- /dev/null +++ b/src/Reddit/Delete.php @@ -0,0 +1,45 @@ + config('app.name').'/1.0', + ]; + } + + public function delete(string $id): Response + { + $response = $this->http() + ->withToken($this->accessToken()) + ->withHeaders($this->redditHeaders()) + ->asForm() + ->post(self::API_URL.'/api/del', [ + 'id' => $id, + ]); + + return $this->fromHttp($response, fn () => [ + 'deleted' => true, + ]); + } +} diff --git a/src/Reddit/Media.php b/src/Reddit/Media.php new file mode 100644 index 0000000..f35fc05 --- /dev/null +++ b/src/Reddit/Media.php @@ -0,0 +1,75 @@ + config('app.name').'/1.0', + ]; + } + + public function upload(array $item): Response + { + // Get upload lease + $leaseResponse = $this->http() + ->withToken($this->accessToken()) + ->withHeaders($this->redditHeaders()) + ->asForm() + ->post(self::API_URL.'/api/media/asset.json', [ + 'filepath' => $item['name'] ?? 'image.jpg', + 'mimetype' => mime_content_type($item['path']), + ]); + + if (! $leaseResponse->successful()) { + return $this->fromHttp($leaseResponse); + } + + $leaseData = $leaseResponse->json(); + $uploadUrl = $leaseData['args']['action'] ?? null; + + if (! $uploadUrl) { + return $this->error('Failed to get upload URL'); + } + + // Build S3 upload fields + $fields = []; + foreach ($leaseData['args']['fields'] ?? [] as $field) { + $fields[$field['name']] = $field['value']; + } + + // Upload to S3 + $s3Response = $this->http() + ->attach('file', file_get_contents($item['path']), $item['name'] ?? 'image.jpg') + ->post("https:{$uploadUrl}", $fields); + + if (! $s3Response->successful()) { + return $this->error('Failed to upload media'); + } + + return $this->ok([ + 'url' => "https:{$uploadUrl}/{$fields['key']}", + ]); + } +} diff --git a/src/Reddit/Post.php b/src/Reddit/Post.php new file mode 100644 index 0000000..88f5ae8 --- /dev/null +++ b/src/Reddit/Post.php @@ -0,0 +1,105 @@ + config('app.name').'/1.0', + ]; + } + + public function publish(string $text, Collection $media, array $params = []): Response + { + $subreddit = $params['subreddit'] ?? null; + + if (! $subreddit) { + return $this->error('Subreddit is required'); + } + + $postData = [ + 'api_type' => 'json', + 'kind' => 'self', // Text post + 'sr' => $subreddit, + 'title' => $params['title'] ?? substr($text, 0, 300), + 'text' => $text, + 'nsfw' => $params['nsfw'] ?? false, + 'spoiler' => $params['spoiler'] ?? false, + ]; + + // Handle link post + if (isset($params['url'])) { + $postData['kind'] = 'link'; + $postData['url'] = $params['url']; + unset($postData['text']); + } + + // Handle image post + if ($media->isNotEmpty() && ! isset($params['url'])) { + $image = $media->first(); + $mediaUploader = (new Media)->withToken($this->getToken()); + $uploadResult = $mediaUploader->upload($image); + + if ($uploadResult->hasError()) { + return $uploadResult; + } + + $postData['kind'] = 'image'; + $postData['url'] = $uploadResult->get('url'); + unset($postData['text']); + } + + $response = $this->http() + ->withToken($this->accessToken()) + ->withHeaders($this->redditHeaders()) + ->asForm() + ->post(self::API_URL.'/api/submit', $postData); + + return $this->fromHttp($response, function ($data) { + $postData = $data['json']['data'] ?? []; + + return [ + 'id' => $postData['id'] ?? $postData['name'] ?? '', + 'url' => $postData['url'] ?? '', + ]; + }); + } + + /** + * Get external URL to a Reddit post. + */ + public static function externalPostUrl(string $subreddit, string $postId): string + { + return "https://www.reddit.com/r/{$subreddit}/comments/{$postId}"; + } + + /** + * Get external URL to a Reddit profile. + */ + public static function externalAccountUrl(string $username): string + { + return "https://www.reddit.com/user/{$username}"; + } +} diff --git a/src/Reddit/Read.php b/src/Reddit/Read.php new file mode 100644 index 0000000..3262147 --- /dev/null +++ b/src/Reddit/Read.php @@ -0,0 +1,99 @@ + config('app.name').'/1.0', + ]; + } + + public function get(string $id): Response + { + $response = $this->http() + ->withToken($this->accessToken()) + ->withHeaders($this->redditHeaders()) + ->get(self::API_URL.'/api/info', [ + 'id' => $id, + ]); + + return $this->fromHttp($response, function ($data) { + $post = $data['data']['children'][0]['data'] ?? null; + + if (! $post) { + return ['error' => 'Post not found']; + } + + return [ + 'id' => $post['id'], + 'title' => $post['title'] ?? '', + 'text' => $post['selftext'] ?? '', + 'url' => $post['url'] ?? null, + 'subreddit' => $post['subreddit'] ?? null, + ]; + }); + } + + /** + * Get the authenticated user's account info. + */ + public function me(): Response + { + $response = $this->http() + ->withToken($this->accessToken()) + ->withHeaders($this->redditHeaders()) + ->get(self::API_URL.'/api/v1/me'); + + return $this->fromHttp($response, fn ($data) => [ + 'id' => $data['id'], + 'name' => $data['name'], + 'username' => $data['name'], + 'image' => $data['icon_img'] ?? $data['snoovatar_img'] ?? null, + ]); + } + + public function list(array $params = []): Response + { + $username = $params['username'] ?? null; + + if (! $username) { + return $this->error('username is required'); + } + + $response = $this->http() + ->withToken($this->accessToken()) + ->withHeaders($this->redditHeaders()) + ->get(self::API_URL."/user/{$username}/submitted", [ + 'limit' => $params['limit'] ?? 25, + ]); + + return $this->fromHttp($response, fn ($data) => [ + 'posts' => array_map(fn ($child) => [ + 'id' => $child['data']['id'] ?? '', + 'title' => $child['data']['title'] ?? '', + 'subreddit' => $child['data']['subreddit'] ?? '', + ], $data['data']['children'] ?? []), + 'after' => $data['data']['after'] ?? null, + ]); + } +} diff --git a/src/Reddit/Subreddits.php b/src/Reddit/Subreddits.php new file mode 100644 index 0000000..fb1c599 --- /dev/null +++ b/src/Reddit/Subreddits.php @@ -0,0 +1,57 @@ + config('app.name').'/1.0', + ]; + } + + public function listEntities(array $params = []): Response + { + $response = $this->http() + ->withToken($this->accessToken()) + ->withHeaders($this->redditHeaders()) + ->get(self::API_URL.'/subreddits/mine/subscriber', [ + 'limit' => $params['limit'] ?? 100, + ]); + + return $this->fromHttp($response, function ($data) { + $subreddits = []; + + foreach ($data['data']['children'] ?? [] as $child) { + $sub = $child['data'] ?? []; + $subreddits[] = [ + 'id' => $sub['id'] ?? '', + 'name' => $sub['display_name'] ?? '', + 'title' => $sub['title'] ?? '', + 'subscribers' => $sub['subscribers'] ?? 0, + 'icon' => $sub['icon_img'] ?? $sub['community_icon'] ?? null, + ]; + } + + return $subreddits; + }); + } +} diff --git a/src/TikTok/Auth.php b/src/TikTok/Auth.php new file mode 100644 index 0000000..65fd08a --- /dev/null +++ b/src/TikTok/Auth.php @@ -0,0 +1,112 @@ + $this->clientId, + 'redirect_uri' => $this->redirectUrl, + 'scope' => implode(',', $this->scope), + 'state' => $this->values['state'] ?? '', + 'response_type' => 'code', + ]; + + return $this->buildUrl(self::AUTH_URL, $params); + } + + public function requestAccessToken(array $params = []): array + { + $response = $this->http() + ->asForm() + ->post(self::API_URL.'/oauth/token/', [ + 'client_key' => $this->clientId, + 'client_secret' => $this->clientSecret, + 'code' => $params['code'], + 'grant_type' => 'authorization_code', + 'redirect_uri' => $this->redirectUrl, + ]) + ->json(); + + if (isset($response['error'])) { + return [ + 'error' => $response['error_description'] ?? $response['error'], + ]; + } + + return [ + 'access_token' => $response['access_token'], + 'refresh_token' => $response['refresh_token'] ?? '', + 'expires_in' => Carbon::now('UTC')->addSeconds($response['expires_in'] ?? 86400)->timestamp, + 'open_id' => $response['open_id'] ?? '', + ]; + } + + public function refresh(): Response + { + $response = $this->http() + ->asForm() + ->post(self::API_URL.'/oauth/token/', [ + 'client_key' => $this->clientId, + 'client_secret' => $this->clientSecret, + 'grant_type' => 'refresh_token', + 'refresh_token' => $this->getToken()['refresh_token'] ?? '', + ]); + + return $this->fromHttp($response, fn ($data) => [ + 'access_token' => $data['access_token'], + 'refresh_token' => $data['refresh_token'] ?? '', + 'expires_in' => Carbon::now('UTC')->addSeconds($data['expires_in'] ?? 86400)->timestamp, + 'open_id' => $data['open_id'] ?? '', + ]); + } + + public function getAccount(): Response + { + return $this->error('Use Read class with token for account info'); + } +} diff --git a/src/TikTok/Post.php b/src/TikTok/Post.php new file mode 100644 index 0000000..5aeed7a --- /dev/null +++ b/src/TikTok/Post.php @@ -0,0 +1,98 @@ +isEmpty()) { + return $this->error('TikTok requires video content'); + } + + $video = $media->first(); + $fileSize = filesize($video['path']); + + // Step 1: Initialise upload + $initResponse = $this->http() + ->withToken($this->accessToken()) + ->post(self::API_URL.'/post/publish/video/init/', [ + 'post_info' => [ + 'title' => $text, + 'privacy_level' => $params['privacy_level'] ?? 'SELF_ONLY', + 'disable_duet' => $params['disable_duet'] ?? false, + 'disable_stitch' => $params['disable_stitch'] ?? false, + 'disable_comment' => $params['disable_comment'] ?? false, + ], + 'source_info' => [ + 'source' => 'FILE_UPLOAD', + 'video_size' => $fileSize, + 'chunk_size' => self::CHUNK_SIZE, + 'total_chunk_count' => (int) ceil($fileSize / self::CHUNK_SIZE), + ], + ]); + + if (! $initResponse->successful()) { + return $this->fromHttp($initResponse); + } + + $initData = $initResponse->json(); + $publishId = $initData['data']['publish_id'] ?? null; + $uploadUrl = $initData['data']['upload_url'] ?? null; + + if (! $publishId || ! $uploadUrl) { + return $this->error('Failed to initialise video upload'); + } + + // Step 2: Upload video + $uploadResponse = $this->http() + ->attach('video', file_get_contents($video['path']), $video['name'] ?? 'video.mp4') + ->put($uploadUrl); + + if (! $uploadResponse->successful()) { + return $this->error('Failed to upload video'); + } + + return $this->ok([ + 'id' => $publishId, + ]); + } + + /** + * Get external URL to a TikTok video. + */ + public static function externalPostUrl(string $username, string $postId): string + { + return "https://www.tiktok.com/@{$username}/video/{$postId}"; + } + + /** + * Get external URL to a TikTok profile. + */ + public static function externalAccountUrl(string $username): string + { + return "https://www.tiktok.com/@{$username}"; + } +} diff --git a/src/TikTok/Read.php b/src/TikTok/Read.php new file mode 100644 index 0000000..dbb1b78 --- /dev/null +++ b/src/TikTok/Read.php @@ -0,0 +1,58 @@ +error('TikTok does not support fetching videos by ID'); + } + + /** + * Get the authenticated user's account info. + */ + public function me(): Response + { + $response = $this->http() + ->withToken($this->accessToken()) + ->get(self::API_URL.'/user/info/', [ + 'fields' => 'open_id,union_id,avatar_url,display_name,username', + ]); + + return $this->fromHttp($response, function ($data) { + $user = $data['data']['user'] ?? $data['user'] ?? $data; + + return [ + 'id' => $user['open_id'] ?? $user['union_id'] ?? '', + 'name' => $user['display_name'] ?? '', + 'username' => $user['username'] ?? '', + 'image' => $user['avatar_url'] ?? null, + ]; + }); + } + + public function list(array $params = []): Response + { + // TikTok doesn't provide a list videos API for creators + return $this->error('TikTok does not support listing videos via API'); + } +} diff --git a/src/Twitter/Auth.php b/src/Twitter/Auth.php new file mode 100644 index 0000000..47bc49a --- /dev/null +++ b/src/Twitter/Auth.php @@ -0,0 +1,168 @@ +generateCodeVerifier(); + $codeChallenge = $this->generateCodeChallenge($codeVerifier); + $state = $this->state ?? Str::random(40); + + Cache::put("twitter_pkce_{$state}", $codeVerifier, now()->addMinutes(10)); + + return $this->buildUrl(self::AUTH_URL, [ + 'response_type' => 'code', + 'client_id' => $this->clientId, + 'redirect_uri' => $this->redirectUrl, + 'scope' => $this->scope, + 'state' => $state, + 'code_challenge' => $codeChallenge, + 'code_challenge_method' => 'S256', + ]); + } + + public function requestAccessToken(array $params): array + { + $state = $params['state'] ?? ''; + $codeVerifier = Cache::pull("twitter_pkce_{$state}"); + + if (! $codeVerifier) { + return ['error' => 'Invalid or expired authorisation code']; + } + + $response = $this->http() + ->asForm() + ->withBasicAuth($this->clientId, $this->clientSecret) + ->post(self::API_URL.'/2/oauth2/token', [ + 'code' => $params['code'], + 'grant_type' => 'authorization_code', + 'client_id' => $this->clientId, + 'redirect_uri' => $this->redirectUrl, + 'code_verifier' => $codeVerifier, + ]) + ->json(); + + if (isset($response['error'])) { + return ['error' => $response['error_description'] ?? $response['error'] ?? 'Unknown error']; + } + + $expiresIn = $response['expires_in'] ?? 7200; + + return [ + 'access_token' => $response['access_token'], + 'refresh_token' => $response['refresh_token'] ?? null, + 'expires_in' => Carbon::now('UTC')->addSeconds($expiresIn)->timestamp, + 'token_type' => $response['token_type'] ?? 'bearer', + 'scope' => $response['scope'] ?? $this->scope, + ]; + } + + public function getAccount(): Response + { + $response = $this->http() + ->withToken($this->accessToken()) + ->get(self::API_URL.'/2/users/me', [ + 'user.fields' => 'id,name,username,profile_image_url', + ]); + + return $this->fromHttp($response, fn ($data) => [ + 'id' => $data['data']['id'], + 'name' => $data['data']['name'], + 'username' => $data['data']['username'], + 'image' => $data['data']['profile_image_url'] ?? null, + ]); + } + + public function refresh(): Response + { + $token = $this->getToken(); + + if (! isset($token['refresh_token'])) { + return $this->unauthorized('No refresh token available'); + } + + $response = $this->http() + ->asForm() + ->withBasicAuth($this->clientId, $this->clientSecret) + ->post(self::API_URL.'/2/oauth2/token', [ + 'refresh_token' => $token['refresh_token'], + 'grant_type' => 'refresh_token', + 'client_id' => $this->clientId, + ]); + + return $this->fromHttp($response, fn ($data) => [ + 'access_token' => $data['access_token'], + 'refresh_token' => $data['refresh_token'] ?? $token['refresh_token'], + 'expires_in' => Carbon::now('UTC')->addSeconds($data['expires_in'] ?? 7200)->timestamp, + 'token_type' => $data['token_type'] ?? 'bearer', + ]); + } + + /** + * Generate a cryptographically secure code verifier for PKCE. + */ + private function generateCodeVerifier(): string + { + return rtrim(strtr(base64_encode(random_bytes(32)), '+/', '-_'), '='); + } + + /** + * Generate a code challenge from the code verifier using SHA256. + */ + private function generateCodeChallenge(string $codeVerifier): string + { + return rtrim(strtr(base64_encode(hash('sha256', $codeVerifier, true)), '+/', '-_'), '='); + } + + /** + * Get external URL to a user's profile. + */ + public static function externalAccountUrl(string $username): string + { + return "https://x.com/{$username}"; + } +} diff --git a/src/Twitter/Delete.php b/src/Twitter/Delete.php new file mode 100644 index 0000000..2f521b6 --- /dev/null +++ b/src/Twitter/Delete.php @@ -0,0 +1,34 @@ +http() + ->withToken($this->accessToken()) + ->delete(self::API_URL."/2/tweets/{$id}"); + + return $this->fromHttp($response, fn ($data) => [ + 'deleted' => $data['data']['deleted'] ?? false, + ]); + } +} diff --git a/src/Twitter/Media.php b/src/Twitter/Media.php new file mode 100644 index 0000000..0e3d0a3 --- /dev/null +++ b/src/Twitter/Media.php @@ -0,0 +1,119 @@ + 5MB + if ($fileSize > self::CHUNK_SIZE) { + return $this->uploadChunked($filePath, $fileSize, mime_content_type($filePath)); + } + + return $this->uploadSimple($filePath); + } + + /** + * Simple upload for small files. + */ + private function uploadSimple(string $filePath): Response + { + $response = $this->http() + ->withToken($this->accessToken()) + ->attach('media', file_get_contents($filePath), basename($filePath)) + ->post(self::UPLOAD_URL.'/1.1/media/upload.json'); + + return $this->fromHttp($response, fn ($data) => [ + 'media_id' => (string) $data['media_id'], + 'media_id_string' => $data['media_id_string'], + ]); + } + + /** + * Chunked upload for large files. + */ + private function uploadChunked(string $filePath, int $fileSize, string $mimeType): Response + { + // INIT + $initResponse = $this->http() + ->withToken($this->accessToken()) + ->asForm() + ->post(self::UPLOAD_URL.'/1.1/media/upload.json', [ + 'command' => 'INIT', + 'total_bytes' => $fileSize, + 'media_type' => $mimeType, + ]); + + if (! $initResponse->successful()) { + return $this->fromHttp($initResponse); + } + + $mediaId = $initResponse->json('media_id_string'); + + // APPEND chunks + $handle = fopen($filePath, 'rb'); + $segmentIndex = 0; + + while (! feof($handle)) { + $chunk = fread($handle, self::CHUNK_SIZE); + + $appendResponse = $this->http() + ->withToken($this->accessToken()) + ->attach('media', $chunk, 'chunk') + ->post(self::UPLOAD_URL.'/1.1/media/upload.json', [ + 'command' => 'APPEND', + 'media_id' => $mediaId, + 'segment_index' => $segmentIndex, + ]); + + if (! $appendResponse->successful()) { + fclose($handle); + + return $this->fromHttp($appendResponse); + } + + $segmentIndex++; + } + + fclose($handle); + + // FINALIZE + $finalizeResponse = $this->http() + ->withToken($this->accessToken()) + ->asForm() + ->post(self::UPLOAD_URL.'/1.1/media/upload.json', [ + 'command' => 'FINALIZE', + 'media_id' => $mediaId, + ]); + + return $this->fromHttp($finalizeResponse, fn ($data) => [ + 'media_id' => $data['media_id_string'], + 'media_id_string' => $data['media_id_string'], + ]); + } +} diff --git a/src/Twitter/Post.php b/src/Twitter/Post.php new file mode 100644 index 0000000..c4bb7f4 --- /dev/null +++ b/src/Twitter/Post.php @@ -0,0 +1,76 @@ + $text]; + + // Handle media attachments + if ($media->isNotEmpty()) { + $mediaUploader = (new Media)->withToken($this->token); + $mediaIds = []; + + foreach ($media as $item) { + $result = $mediaUploader->upload($item); + + if ($result->hasError()) { + return $result; + } + + $mediaIds[] = $result->get('media_id'); + } + + if (! empty($mediaIds)) { + $tweetData['media'] = ['media_ids' => $mediaIds]; + } + } + + // Handle reply + if (! empty($params['reply_to'])) { + $tweetData['reply'] = ['in_reply_to_tweet_id' => $params['reply_to']]; + } + + // Handle quote tweet + if (! empty($params['quote_tweet_id'])) { + $tweetData['quote_tweet_id'] = $params['quote_tweet_id']; + } + + $response = $this->http() + ->withToken($this->accessToken()) + ->post(self::API_URL.'/2/tweets', $tweetData); + + return $this->fromHttp($response, fn ($data) => [ + 'id' => $data['data']['id'], + 'text' => $data['data']['text'], + ]); + } + + /** + * Get external URL to a tweet. + */ + public static function externalPostUrl(string $username, string $postId): string + { + return "https://x.com/{$username}/status/{$postId}"; + } +} diff --git a/src/Twitter/Read.php b/src/Twitter/Read.php new file mode 100644 index 0000000..a7c8ece --- /dev/null +++ b/src/Twitter/Read.php @@ -0,0 +1,70 @@ +http() + ->withToken($this->accessToken()) + ->get(self::API_URL."/2/tweets/{$id}", [ + 'tweet.fields' => 'id,text,created_at,author_id,public_metrics', + 'expansions' => 'author_id', + 'user.fields' => 'id,name,username,profile_image_url', + ]); + + return $this->fromHttp($response, fn ($data) => [ + 'id' => $data['data']['id'], + 'text' => $data['data']['text'], + 'created_at' => $data['data']['created_at'] ?? null, + 'author_id' => $data['data']['author_id'] ?? null, + 'metrics' => $data['data']['public_metrics'] ?? [], + 'author' => $data['includes']['users'][0] ?? null, + ]); + } + + public function list(array $params = []): Response + { + $userId = $params['user_id'] ?? null; + + if (! $userId) { + return $this->error('user_id is required'); + } + + $queryParams = [ + 'tweet.fields' => 'id,text,created_at,public_metrics', + 'max_results' => $params['limit'] ?? 10, + ]; + + if (! empty($params['pagination_token'])) { + $queryParams['pagination_token'] = $params['pagination_token']; + } + + $response = $this->http() + ->withToken($this->accessToken()) + ->get(self::API_URL."/2/users/{$userId}/tweets", $queryParams); + + return $this->fromHttp($response, fn ($data) => [ + 'tweets' => $data['data'] ?? [], + 'meta' => $data['meta'] ?? [], + ]); + } +} diff --git a/src/VK/Auth.php b/src/VK/Auth.php new file mode 100644 index 0000000..1ba885a --- /dev/null +++ b/src/VK/Auth.php @@ -0,0 +1,91 @@ + $this->clientId, + 'redirect_uri' => $this->redirectUrl, + 'display' => 'page', + 'scope' => implode(',', $this->scope), + 'response_type' => 'code', + 'v' => self::API_VERSION, + 'state' => $this->values['state'] ?? '', + ]; + + return 'https://oauth.vk.com/authorize?'.http_build_query($params); + } + + public function requestAccessToken(array $params = []): array + { + $response = $this->http() + ->get('https://oauth.vk.com/access_token', [ + 'client_id' => $this->clientId, + 'client_secret' => $this->clientSecret, + 'redirect_uri' => $this->redirectUrl, + 'code' => $params['code'] ?? '', + ]) + ->json(); + + if (isset($response['error'])) { + return [ + 'error' => $response['error_description'] ?? $response['error'] ?? 'Unknown error', + ]; + } + + return [ + 'access_token' => $response['access_token'] ?? '', + 'expires_in' => isset($response['expires_in']) + ? now('UTC')->addSeconds($response['expires_in'])->timestamp + : null, + 'data' => [ + 'user_id' => $response['user_id'] ?? null, + 'email' => $response['email'] ?? null, + ], + ]; + } + + public function getAccount(): Response + { + return $this->error('Use Read class with token for account info'); + } +} diff --git a/src/VK/Delete.php b/src/VK/Delete.php new file mode 100644 index 0000000..5b25fbf --- /dev/null +++ b/src/VK/Delete.php @@ -0,0 +1,59 @@ +ownerId = $ownerId; + + return $this; + } + + public function delete(string $id): Response + { + $ownerId = $this->ownerId ?? $this->getToken()['data']['user_id'] ?? ''; + + $response = $this->http() + ->get(self::API_URL.'/wall.delete', [ + 'owner_id' => $ownerId, + 'post_id' => (int) $id, + 'access_token' => $this->accessToken(), + 'v' => self::API_VERSION, + ]) + ->json(); + + if (isset($response['error'])) { + return $this->error($response['error']['error_msg'] ?? 'Failed to delete post'); + } + + return $this->ok([ + 'deleted' => true, + ]); + } +} diff --git a/src/VK/Groups.php b/src/VK/Groups.php new file mode 100644 index 0000000..65039d1 --- /dev/null +++ b/src/VK/Groups.php @@ -0,0 +1,59 @@ + 1, + 'filter' => 'admin,editor,moder', + 'fields' => 'photo_200,screen_name', + 'access_token' => $this->accessToken(), + 'v' => self::API_VERSION, + ]; + + $response = $this->http() + ->get(self::API_URL.'/groups.get', $requestParams) + ->json(); + + if (isset($response['error'])) { + return $this->error($response['error']['error_msg'] ?? 'Failed to get groups'); + } + + $groups = []; + + foreach ($response['response']['items'] ?? [] as $group) { + $groups[] = [ + 'id' => $group['id'], + 'name' => $group['name'], + 'screen_name' => $group['screen_name'] ?? '', + 'image' => $group['photo_200'] ?? '', + 'is_admin' => $group['is_admin'] ?? false, + 'admin_level' => $group['admin_level'] ?? 0, + ]; + } + + return $this->ok($groups); + } +} diff --git a/src/VK/Media.php b/src/VK/Media.php new file mode 100644 index 0000000..103833b --- /dev/null +++ b/src/VK/Media.php @@ -0,0 +1,116 @@ +groupId = $groupId; + + return $this; + } + + public function upload(array $item): Response + { + $type = $item['type'] ?? 'image'; + + if ($type === 'video') { + return $this->error('Video upload not yet implemented'); + } + + return $this->uploadPhoto($item['path']); + } + + protected function uploadPhoto(string $path): Response + { + // Get upload server + $serverParams = [ + 'access_token' => $this->accessToken(), + 'v' => self::API_VERSION, + ]; + + if ($this->groupId) { + $serverParams['group_id'] = $this->groupId; + } + + $serverResponse = $this->http() + ->get(self::API_URL.'/photos.getWallUploadServer', $serverParams) + ->json(); + + if (! isset($serverResponse['response']['upload_url'])) { + return $this->error('Failed to get upload server'); + } + + $uploadUrl = $serverResponse['response']['upload_url']; + + // Upload the file + try { + $uploadResponse = $this->http() + ->attach('photo', file_get_contents($path), basename($path)) + ->post($uploadUrl) + ->json(); + + if (empty($uploadResponse['photo']) || $uploadResponse['photo'] === '[]') { + return $this->error('Failed to upload photo'); + } + } catch (\Exception $e) { + return $this->error('Upload failed: '.$e->getMessage()); + } + + // Save the photo + $saveParams = [ + 'server' => $uploadResponse['server'], + 'photo' => $uploadResponse['photo'], + 'hash' => $uploadResponse['hash'], + 'access_token' => $this->accessToken(), + 'v' => self::API_VERSION, + ]; + + if ($this->groupId) { + $saveParams['group_id'] = $this->groupId; + } + + $saveResponse = $this->http() + ->get(self::API_URL.'/photos.saveWallPhoto', $saveParams) + ->json(); + + if (! isset($saveResponse['response'][0])) { + return $this->error('Failed to save photo'); + } + + $photo = $saveResponse['response'][0]; + + return $this->ok([ + 'attachment' => "photo{$photo['owner_id']}_{$photo['id']}", + 'owner_id' => $photo['owner_id'], + 'id' => $photo['id'], + ]); + } +} diff --git a/src/VK/Post.php b/src/VK/Post.php new file mode 100644 index 0000000..b6b6651 --- /dev/null +++ b/src/VK/Post.php @@ -0,0 +1,129 @@ +getToken()['data']['user_id'] ?? ''); + + $postParams = [ + 'owner_id' => $ownerId, + 'message' => $text, + 'from_group' => $groupId ? 1 : 0, + 'access_token' => $this->accessToken(), + 'v' => self::API_VERSION, + ]; + + // Handle media attachments + if ($media->isNotEmpty()) { + $attachments = []; + $mediaUploader = (new Media)->withToken($this->getToken()); + + if ($groupId) { + $mediaUploader->forGroup((int) $groupId); + } + + foreach ($media as $item) { + $result = $mediaUploader->upload($item); + + if ($result->hasError()) { + continue; // Skip failed uploads + } + + $attachments[] = $result->get('attachment'); + } + + if (! empty($attachments)) { + $postParams['attachments'] = implode(',', $attachments); + } + } + + // Handle link attachment + if (! empty($params['link'])) { + $existingAttachments = $postParams['attachments'] ?? ''; + $postParams['attachments'] = $existingAttachments + ? $existingAttachments.','.$params['link'] + : $params['link']; + } + + // Handle scheduled publishing + if (! empty($params['publish_date'])) { + $postParams['publish_date'] = $params['publish_date']; + } + + $response = $this->http() + ->get(self::API_URL.'/wall.post', $postParams) + ->json(); + + if (isset($response['error'])) { + $errorCode = $response['error']['error_code'] ?? 0; + + // Handle rate limiting (error code 9) + if ($errorCode === 9) { + return $this->rateLimit(60); + } + + // Handle captcha required (error code 14) + if ($errorCode === 14) { + return $this->error('Captcha required. Please post from VK directly.'); + } + + return $this->error($response['error']['error_msg'] ?? 'Failed to publish post'); + } + + $postId = $response['response']['post_id'] ?? null; + + if (! $postId) { + return $this->error('Post created but no ID returned'); + } + + return $this->ok([ + 'id' => (string) $postId, + 'owner_id' => $ownerId, + 'post_id' => $postId, + ]); + } + + /** + * Get external URL to a VK post. + */ + public static function externalPostUrl(string $ownerId, string $postId): string + { + return "https://vk.com/wall{$ownerId}_{$postId}"; + } + + /** + * Get external URL to a VK profile. + */ + public static function externalAccountUrl(string $accountId, ?string $screenName = null): string + { + if ($screenName) { + return "https://vk.com/{$screenName}"; + } + + return "https://vk.com/id{$accountId}"; + } +} diff --git a/src/VK/Read.php b/src/VK/Read.php new file mode 100644 index 0000000..2156abc --- /dev/null +++ b/src/VK/Read.php @@ -0,0 +1,117 @@ +vkRequest('wall.getById', [ + 'posts' => $id, + ]); + + if (isset($response['error'])) { + return $this->error($response['error']['error_msg'] ?? 'Failed to get post'); + } + + $post = $response['response'][0] ?? null; + + if (! $post) { + return $this->error('Post not found'); + } + + return $this->ok([ + 'id' => $post['id'], + 'text' => $post['text'] ?? '', + 'date' => $post['date'] ?? null, + 'owner_id' => $post['owner_id'] ?? null, + ]); + } + + /** + * Get the authenticated user's account info. + */ + public function me(): Response + { + $response = $this->vkRequest('users.get', [ + 'fields' => 'photo_200,screen_name', + ]); + + if (isset($response['error'])) { + return $this->error($response['error']['error_msg'] ?? 'Failed to get user information'); + } + + $user = $response['response'][0] ?? null; + + if (! $user) { + return $this->error('User not found'); + } + + $name = trim(($user['first_name'] ?? '').' '.($user['last_name'] ?? '')); + + return $this->ok([ + 'id' => (string) $user['id'], + 'name' => $name ?: 'VK User', + 'username' => $user['screen_name'] ?? '', + 'image' => $user['photo_200'] ?? '', + 'data' => [ + 'user_id' => $user['id'], + 'screen_name' => $user['screen_name'] ?? '', + ], + ]); + } + + public function list(array $params = []): Response + { + $ownerId = $params['owner_id'] ?? $this->getToken()['data']['user_id'] ?? null; + + if (! $ownerId) { + return $this->error('owner_id is required'); + } + + $response = $this->vkRequest('wall.get', [ + 'owner_id' => $ownerId, + 'count' => $params['limit'] ?? 20, + 'offset' => $params['offset'] ?? 0, + ]); + + if (isset($response['error'])) { + return $this->error($response['error']['error_msg'] ?? 'Failed to get posts'); + } + + return $this->ok([ + 'posts' => $response['response']['items'] ?? [], + 'count' => $response['response']['count'] ?? 0, + ]); + } + + protected function vkRequest(string $method, array $params = []): array + { + $params['access_token'] = $this->accessToken(); + $params['v'] = self::API_VERSION; + + $response = $this->http() + ->get(self::API_URL.'/'.$method, $params); + + return $response->json() ?? []; + } +} diff --git a/src/YouTube/Auth.php b/src/YouTube/Auth.php new file mode 100644 index 0000000..0f06035 --- /dev/null +++ b/src/YouTube/Auth.php @@ -0,0 +1,117 @@ + $this->clientId, + 'redirect_uri' => $this->redirectUrl, + 'scope' => implode(' ', $this->scope), + 'state' => $this->values['state'] ?? '', + 'response_type' => 'code', + 'access_type' => 'offline', + 'prompt' => 'consent', + ]; + + return $this->buildUrl(self::AUTH_URL, $params); + } + + public function requestAccessToken(array $params = []): array + { + $response = $this->http() + ->asForm() + ->post(self::TOKEN_URL, [ + 'client_id' => $this->clientId, + 'client_secret' => $this->clientSecret, + 'code' => $params['code'], + 'grant_type' => 'authorization_code', + 'redirect_uri' => $this->redirectUrl, + ]) + ->json(); + + if (isset($response['error'])) { + return [ + 'error' => $response['error_description'] ?? $response['error'], + ]; + } + + return [ + 'access_token' => $response['access_token'], + 'refresh_token' => $response['refresh_token'] ?? '', + 'expires_in' => Carbon::now('UTC')->addSeconds($response['expires_in'] ?? 3600)->timestamp, + 'scope' => $response['scope'] ?? '', + ]; + } + + public function refresh(): Response + { + $response = $this->http() + ->asForm() + ->post(self::TOKEN_URL, [ + 'client_id' => $this->clientId, + 'client_secret' => $this->clientSecret, + 'refresh_token' => $this->getToken()['refresh_token'] ?? '', + 'grant_type' => 'refresh_token', + ]); + + return $this->fromHttp($response, fn ($data) => [ + 'access_token' => $data['access_token'], + 'expires_in' => Carbon::now('UTC')->addSeconds($data['expires_in'] ?? 3600)->timestamp, + 'refresh_token' => $data['refresh_token'] ?? $this->getToken()['refresh_token'] ?? '', + ]); + } + + public function getAccount(): Response + { + return $this->error('Use Read class with token for account info'); + } +} diff --git a/src/YouTube/Comment.php b/src/YouTube/Comment.php new file mode 100644 index 0000000..2137497 --- /dev/null +++ b/src/YouTube/Comment.php @@ -0,0 +1,43 @@ +http() + ->withToken($this->accessToken()) + ->post(self::API_URL.'/commentThreads?part=snippet', [ + 'snippet' => [ + 'videoId' => $postId, + 'topLevelComment' => [ + 'snippet' => [ + 'textOriginal' => $text, + ], + ], + ], + ]); + + return $this->fromHttp($response, fn ($data) => [ + 'id' => $data['id'] ?? '', + ]); + } +} diff --git a/src/YouTube/Delete.php b/src/YouTube/Delete.php new file mode 100644 index 0000000..7e22532 --- /dev/null +++ b/src/YouTube/Delete.php @@ -0,0 +1,36 @@ +http() + ->withToken($this->accessToken()) + ->delete(self::API_URL.'/videos', [ + 'id' => $id, + ]); + + return $this->fromHttp($response, fn () => [ + 'deleted' => true, + ]); + } +} diff --git a/src/YouTube/Post.php b/src/YouTube/Post.php new file mode 100644 index 0000000..aa6c42f --- /dev/null +++ b/src/YouTube/Post.php @@ -0,0 +1,107 @@ +isEmpty()) { + return $this->error('YouTube requires video content'); + } + + $video = $media->first(); + + // Prepare video metadata + $metadata = [ + 'snippet' => [ + 'title' => $params['title'] ?? 'Untitled Video', + 'description' => $text, + 'tags' => $params['tags'] ?? [], + 'categoryId' => $params['category_id'] ?? '22', // 22 = People & Blogs + ], + 'status' => [ + 'privacyStatus' => $params['privacy_status'] ?? 'private', + 'selfDeclaredMadeForKids' => $params['made_for_kids'] ?? false, + ], + ]; + + // For scheduled publishing + if (isset($params['publish_at'])) { + $metadata['status']['publishAt'] = Carbon::parse($params['publish_at'])->toIso8601String(); + } + + // Step 1: Start resumable upload + $initResponse = $this->http() + ->withToken($this->accessToken()) + ->withHeaders([ + 'Content-Type' => 'application/json; charset=UTF-8', + 'X-Upload-Content-Length' => filesize($video['path']), + 'X-Upload-Content-Type' => $video['mime_type'] ?? 'video/mp4', + ]) + ->post(self::UPLOAD_URL.'/videos?uploadType=resumable&part=snippet,status', $metadata); + + if (! $initResponse->successful()) { + return $this->fromHttp($initResponse); + } + + $uploadUrl = $initResponse->header('Location'); + + if (! $uploadUrl) { + return $this->error('Failed to get upload URL'); + } + + // Step 2: Upload video content + $uploadResponse = $this->http() + ->withToken($this->accessToken()) + ->withHeaders([ + 'Content-Type' => $video['mime_type'] ?? 'video/mp4', + ]) + ->withBody(file_get_contents($video['path']), $video['mime_type'] ?? 'video/mp4') + ->put($uploadUrl); + + return $this->fromHttp($uploadResponse, fn ($data) => [ + 'id' => $data['id'], + 'url' => "https://www.youtube.com/watch?v={$data['id']}", + ]); + } + + /** + * Get external URL to a YouTube video. + */ + public static function externalPostUrl(string $videoId): string + { + return "https://www.youtube.com/watch?v={$videoId}"; + } + + /** + * Get external URL to a YouTube channel. + */ + public static function externalAccountUrl(string $channelId): string + { + return "https://www.youtube.com/channel/{$channelId}"; + } +} diff --git a/src/YouTube/Read.php b/src/YouTube/Read.php new file mode 100644 index 0000000..1f03538 --- /dev/null +++ b/src/YouTube/Read.php @@ -0,0 +1,101 @@ +http() + ->withToken($this->accessToken()) + ->get(self::API_URL.'/videos', [ + 'part' => 'snippet,statistics', + 'id' => $id, + ]); + + return $this->fromHttp($response, function ($data) { + $video = $data['items'][0] ?? null; + + if (! $video) { + return ['error' => 'Video not found']; + } + + return [ + 'id' => $video['id'], + 'title' => $video['snippet']['title'] ?? '', + 'description' => $video['snippet']['description'] ?? '', + 'thumbnail' => $video['snippet']['thumbnails']['default']['url'] ?? null, + 'views' => $video['statistics']['viewCount'] ?? 0, + ]; + }); + } + + /** + * Get the authenticated user's channel info. + */ + public function me(): Response + { + $response = $this->http() + ->withToken($this->accessToken()) + ->get(self::API_URL.'/channels', [ + 'part' => 'snippet,statistics', + 'mine' => 'true', + ]); + + return $this->fromHttp($response, function ($data) { + $channel = $data['items'][0] ?? null; + + if (! $channel) { + return ['error' => 'No channel found']; + } + + return [ + 'id' => $channel['id'], + 'name' => $channel['snippet']['title'] ?? '', + 'username' => $channel['snippet']['customUrl'] ?? '', + 'image' => $channel['snippet']['thumbnails']['default']['url'] ?? null, + 'subscribers' => $channel['statistics']['subscriberCount'] ?? 0, + ]; + }); + } + + public function list(array $params = []): Response + { + $response = $this->http() + ->withToken($this->accessToken()) + ->get(self::API_URL.'/search', [ + 'part' => 'snippet', + 'forMine' => 'true', + 'type' => 'video', + 'maxResults' => $params['limit'] ?? 25, + 'pageToken' => $params['page_token'] ?? null, + ]); + + return $this->fromHttp($response, fn ($data) => [ + 'videos' => array_map(fn ($item) => [ + 'id' => $item['id']['videoId'] ?? '', + 'title' => $item['snippet']['title'] ?? '', + 'description' => $item['snippet']['description'] ?? '', + 'thumbnail' => $item['snippet']['thumbnails']['default']['url'] ?? null, + ], $data['items'] ?? []), + 'next_page_token' => $data['nextPageToken'] ?? null, + ]); + } +}