feat: extract social providers from app/Plug/Social
LinkedIn, Meta, Pinterest, Reddit, TikTok, Twitter, VK, YouTube providers with Core\Plug\Social namespace alignment. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
84e7ecfc9b
commit
7b2b7968d3
44 changed files with 3594 additions and 0 deletions
17
composer.json
Normal file
17
composer.json
Normal file
|
|
@ -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
|
||||
}
|
||||
147
src/LinkedIn/Auth.php
Normal file
147
src/LinkedIn/Auth.php
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Plug\Social\LinkedIn;
|
||||
|
||||
use Core\Plug\Concern\BuildsResponse;
|
||||
use Core\Plug\Concern\ManagesTokens;
|
||||
use Core\Plug\Concern\UsesHttp;
|
||||
use Core\Plug\Contract\Authenticable;
|
||||
use Core\Plug\Contract\Refreshable;
|
||||
use Core\Plug\Response;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
/**
|
||||
* LinkedIn OAuth2 authentication.
|
||||
*
|
||||
* Supports multiple product types: sign_share, sign_open_id_share, community_management.
|
||||
*/
|
||||
class Auth implements Authenticable, Refreshable
|
||||
{
|
||||
use BuildsResponse;
|
||||
use ManagesTokens;
|
||||
use UsesHttp;
|
||||
|
||||
private const API_URL = 'https://api.linkedin.com';
|
||||
|
||||
private const OAUTH_URL = 'https://www.linkedin.com/oauth';
|
||||
|
||||
private string $apiVersion = 'v2';
|
||||
|
||||
private array $scope = [];
|
||||
|
||||
public function __construct(
|
||||
private readonly string $clientId,
|
||||
private readonly string $clientSecret,
|
||||
private readonly string $redirectUrl,
|
||||
private readonly array $values = []
|
||||
) {
|
||||
$this->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');
|
||||
}
|
||||
}
|
||||
45
src/LinkedIn/Delete.php
Normal file
45
src/LinkedIn/Delete.php
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Plug\Social\LinkedIn;
|
||||
|
||||
use Core\Plug\Concern\BuildsResponse;
|
||||
use Core\Plug\Concern\ManagesTokens;
|
||||
use Core\Plug\Concern\UsesHttp;
|
||||
use Core\Plug\Contract\Deletable;
|
||||
use Core\Plug\Response;
|
||||
|
||||
/**
|
||||
* LinkedIn post deletion.
|
||||
*/
|
||||
class Delete implements Deletable
|
||||
{
|
||||
use BuildsResponse;
|
||||
use ManagesTokens;
|
||||
use UsesHttp;
|
||||
|
||||
private const API_URL = 'https://api.linkedin.com';
|
||||
|
||||
private string $apiVersion = 'v2';
|
||||
|
||||
protected function httpHeaders(): array
|
||||
{
|
||||
return [
|
||||
'X-Restli-Protocol-Version' => '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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
101
src/LinkedIn/Media.php
Normal file
101
src/LinkedIn/Media.php
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Plug\Social\LinkedIn;
|
||||
|
||||
use Core\Plug\Concern\BuildsResponse;
|
||||
use Core\Plug\Concern\ManagesTokens;
|
||||
use Core\Plug\Concern\UsesHttp;
|
||||
use Core\Plug\Contract\MediaUploadable;
|
||||
use Core\Plug\Response;
|
||||
|
||||
/**
|
||||
* LinkedIn media upload.
|
||||
*
|
||||
* Uses two-step process: register upload, then upload file.
|
||||
*/
|
||||
class Media implements MediaUploadable
|
||||
{
|
||||
use BuildsResponse;
|
||||
use ManagesTokens;
|
||||
use UsesHttp;
|
||||
|
||||
private const API_URL = 'https://api.linkedin.com';
|
||||
|
||||
private string $apiVersion = 'v2';
|
||||
|
||||
private ?string $authorUrn = null;
|
||||
|
||||
protected function httpHeaders(): array
|
||||
{
|
||||
return [
|
||||
'X-Restli-Protocol-Version' => '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'] ?? '',
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
69
src/LinkedIn/Pages.php
Normal file
69
src/LinkedIn/Pages.php
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Plug\Social\LinkedIn;
|
||||
|
||||
use Core\Plug\Concern\BuildsResponse;
|
||||
use Core\Plug\Concern\ManagesTokens;
|
||||
use Core\Plug\Concern\UsesHttp;
|
||||
use Core\Plug\Contract\Listable;
|
||||
use Core\Plug\Response;
|
||||
|
||||
/**
|
||||
* LinkedIn organization pages listing.
|
||||
*/
|
||||
class Pages implements Listable
|
||||
{
|
||||
use BuildsResponse;
|
||||
use ManagesTokens;
|
||||
use UsesHttp;
|
||||
|
||||
private const API_URL = 'https://api.linkedin.com';
|
||||
|
||||
private string $apiVersion = 'v2';
|
||||
|
||||
protected function httpHeaders(): array
|
||||
{
|
||||
return [
|
||||
'X-Restli-Protocol-Version' => '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;
|
||||
});
|
||||
}
|
||||
}
|
||||
99
src/LinkedIn/Post.php
Normal file
99
src/LinkedIn/Post.php
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Plug\Social\LinkedIn;
|
||||
|
||||
use Core\Plug\Concern\BuildsResponse;
|
||||
use Core\Plug\Concern\ManagesTokens;
|
||||
use Core\Plug\Concern\UsesHttp;
|
||||
use Core\Plug\Contract\Postable;
|
||||
use Core\Plug\Response;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
/**
|
||||
* LinkedIn post publishing.
|
||||
*/
|
||||
class Post implements Postable
|
||||
{
|
||||
use BuildsResponse;
|
||||
use ManagesTokens;
|
||||
use UsesHttp;
|
||||
|
||||
private const API_URL = 'https://api.linkedin.com';
|
||||
|
||||
private string $apiVersion = 'v2';
|
||||
|
||||
protected function httpHeaders(): array
|
||||
{
|
||||
return [
|
||||
'X-Restli-Protocol-Version' => '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}";
|
||||
}
|
||||
}
|
||||
62
src/LinkedIn/Read.php
Normal file
62
src/LinkedIn/Read.php
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Plug\Social\LinkedIn;
|
||||
|
||||
use Core\Plug\Concern\BuildsResponse;
|
||||
use Core\Plug\Concern\ManagesTokens;
|
||||
use Core\Plug\Concern\UsesHttp;
|
||||
use Core\Plug\Contract\Readable;
|
||||
use Core\Plug\Response;
|
||||
|
||||
/**
|
||||
* LinkedIn account reading.
|
||||
*/
|
||||
class Read implements Readable
|
||||
{
|
||||
use BuildsResponse;
|
||||
use ManagesTokens;
|
||||
use UsesHttp;
|
||||
|
||||
private const API_URL = 'https://api.linkedin.com';
|
||||
|
||||
private string $apiVersion = 'v2';
|
||||
|
||||
protected function httpHeaders(): array
|
||||
{
|
||||
return [
|
||||
'X-Restli-Protocol-Version' => '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');
|
||||
}
|
||||
}
|
||||
112
src/Meta/Auth.php
Normal file
112
src/Meta/Auth.php
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Plug\Social\Meta;
|
||||
|
||||
use Core\Plug\Concern\BuildsResponse;
|
||||
use Core\Plug\Concern\UsesHttp;
|
||||
use Core\Plug\Contract\Authenticable;
|
||||
use Core\Plug\Response;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
/**
|
||||
* Meta (Facebook/Instagram) OAuth2 authentication.
|
||||
*
|
||||
* Supports long-lived token exchange for extended access.
|
||||
*/
|
||||
class Auth implements Authenticable
|
||||
{
|
||||
use BuildsResponse;
|
||||
use UsesHttp;
|
||||
|
||||
private const API_URL = 'https://graph.facebook.com';
|
||||
|
||||
private string $apiVersion;
|
||||
|
||||
private array $scopes = [
|
||||
'business_management',
|
||||
'pages_show_list',
|
||||
'read_insights',
|
||||
'pages_manage_posts',
|
||||
'pages_read_engagement',
|
||||
'pages_manage_engagement',
|
||||
'instagram_basic',
|
||||
'instagram_content_publish',
|
||||
'instagram_manage_insights',
|
||||
'instagram_manage_comments',
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private readonly string $clientId,
|
||||
private readonly string $clientSecret,
|
||||
private readonly string $redirectUrl,
|
||||
private readonly array $values = []
|
||||
) {
|
||||
$this->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');
|
||||
}
|
||||
}
|
||||
42
src/Meta/Delete.php
Normal file
42
src/Meta/Delete.php
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Plug\Social\Meta;
|
||||
|
||||
use Core\Plug\Concern\BuildsResponse;
|
||||
use Core\Plug\Concern\ManagesTokens;
|
||||
use Core\Plug\Concern\UsesHttp;
|
||||
use Core\Plug\Contract\Deletable;
|
||||
use Core\Plug\Response;
|
||||
|
||||
/**
|
||||
* Meta (Facebook/Instagram) post deletion.
|
||||
*/
|
||||
class Delete implements Deletable
|
||||
{
|
||||
use BuildsResponse;
|
||||
use ManagesTokens;
|
||||
use UsesHttp;
|
||||
|
||||
private const API_URL = 'https://graph.facebook.com';
|
||||
|
||||
private string $apiVersion;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
64
src/Meta/Media.php
Normal file
64
src/Meta/Media.php
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Plug\Social\Meta;
|
||||
|
||||
use Core\Plug\Concern\BuildsResponse;
|
||||
use Core\Plug\Concern\ManagesTokens;
|
||||
use Core\Plug\Concern\UsesHttp;
|
||||
use Core\Plug\Contract\MediaUploadable;
|
||||
use Core\Plug\Response;
|
||||
|
||||
/**
|
||||
* Meta (Facebook/Instagram) media upload.
|
||||
*/
|
||||
class Media implements MediaUploadable
|
||||
{
|
||||
use BuildsResponse;
|
||||
use ManagesTokens;
|
||||
use UsesHttp;
|
||||
|
||||
private const API_URL = 'https://graph.facebook.com';
|
||||
|
||||
private string $apiVersion;
|
||||
|
||||
private ?string $pageId = null;
|
||||
|
||||
private ?string $pageToken = null;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->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'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
72
src/Meta/Pages.php
Normal file
72
src/Meta/Pages.php
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Plug\Social\Meta;
|
||||
|
||||
use Core\Plug\Concern\BuildsResponse;
|
||||
use Core\Plug\Concern\ManagesTokens;
|
||||
use Core\Plug\Concern\UsesHttp;
|
||||
use Core\Plug\Contract\Listable;
|
||||
use Core\Plug\Response;
|
||||
|
||||
/**
|
||||
* Meta (Facebook/Instagram) pages listing.
|
||||
*
|
||||
* Lists Facebook Pages and linked Instagram Business accounts.
|
||||
*/
|
||||
class Pages implements Listable
|
||||
{
|
||||
use BuildsResponse;
|
||||
use ManagesTokens;
|
||||
use UsesHttp;
|
||||
|
||||
private const API_URL = 'https://graph.facebook.com';
|
||||
|
||||
private string $apiVersion;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->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;
|
||||
});
|
||||
}
|
||||
}
|
||||
102
src/Meta/Post.php
Normal file
102
src/Meta/Post.php
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Plug\Social\Meta;
|
||||
|
||||
use Core\Plug\Concern\BuildsResponse;
|
||||
use Core\Plug\Concern\ManagesTokens;
|
||||
use Core\Plug\Concern\UsesHttp;
|
||||
use Core\Plug\Contract\Postable;
|
||||
use Core\Plug\Response;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
/**
|
||||
* Meta (Facebook/Instagram) post publishing.
|
||||
*/
|
||||
class Post implements Postable
|
||||
{
|
||||
use BuildsResponse;
|
||||
use ManagesTokens;
|
||||
use UsesHttp;
|
||||
|
||||
private const API_URL = 'https://graph.facebook.com';
|
||||
|
||||
private string $apiVersion;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->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}";
|
||||
}
|
||||
}
|
||||
76
src/Meta/Read.php
Normal file
76
src/Meta/Read.php
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Plug\Social\Meta;
|
||||
|
||||
use Core\Plug\Concern\BuildsResponse;
|
||||
use Core\Plug\Concern\ManagesTokens;
|
||||
use Core\Plug\Concern\UsesHttp;
|
||||
use Core\Plug\Contract\Readable;
|
||||
use Core\Plug\Response;
|
||||
|
||||
/**
|
||||
* Meta (Facebook/Instagram) account reading.
|
||||
*/
|
||||
class Read implements Readable
|
||||
{
|
||||
use BuildsResponse;
|
||||
use ManagesTokens;
|
||||
use UsesHttp;
|
||||
|
||||
private const API_URL = 'https://graph.facebook.com';
|
||||
|
||||
private string $apiVersion;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->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'] ?? [],
|
||||
]);
|
||||
}
|
||||
}
|
||||
108
src/Pinterest/Auth.php
Normal file
108
src/Pinterest/Auth.php
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Plug\Social\Pinterest;
|
||||
|
||||
use Core\Plug\Concern\BuildsResponse;
|
||||
use Core\Plug\Concern\ManagesTokens;
|
||||
use Core\Plug\Concern\UsesHttp;
|
||||
use Core\Plug\Contract\Authenticable;
|
||||
use Core\Plug\Contract\Refreshable;
|
||||
use Core\Plug\Response;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
/**
|
||||
* Pinterest OAuth2 authentication.
|
||||
*
|
||||
* Uses Basic Auth for token requests.
|
||||
*/
|
||||
class Auth implements Authenticable, Refreshable
|
||||
{
|
||||
use BuildsResponse;
|
||||
use ManagesTokens;
|
||||
use UsesHttp;
|
||||
|
||||
private const API_URL = 'https://api.pinterest.com/v5';
|
||||
|
||||
private const AUTH_URL = 'https://www.pinterest.com/oauth';
|
||||
|
||||
private array $scope = ['boards:read', 'boards:write', 'pins:read', 'pins:write', 'user_accounts:read'];
|
||||
|
||||
public function __construct(
|
||||
private readonly string $clientId,
|
||||
private readonly string $clientSecret,
|
||||
private readonly string $redirectUrl,
|
||||
private readonly array $values = []
|
||||
) {}
|
||||
|
||||
public static function identifier(): string
|
||||
{
|
||||
return 'pinterest';
|
||||
}
|
||||
|
||||
public static function name(): string
|
||||
{
|
||||
return 'Pinterest';
|
||||
}
|
||||
|
||||
public function getAuthUrl(): string
|
||||
{
|
||||
$params = [
|
||||
'client_id' => $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');
|
||||
}
|
||||
}
|
||||
47
src/Pinterest/Boards.php
Normal file
47
src/Pinterest/Boards.php
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Plug\Social\Pinterest;
|
||||
|
||||
use Core\Plug\Concern\BuildsResponse;
|
||||
use Core\Plug\Concern\ManagesTokens;
|
||||
use Core\Plug\Concern\UsesHttp;
|
||||
use Core\Plug\Contract\Listable;
|
||||
use Core\Plug\Response;
|
||||
|
||||
/**
|
||||
* Pinterest boards listing.
|
||||
*/
|
||||
class Boards implements Listable
|
||||
{
|
||||
use BuildsResponse;
|
||||
use ManagesTokens;
|
||||
use UsesHttp;
|
||||
|
||||
private const API_URL = 'https://api.pinterest.com/v5';
|
||||
|
||||
public function listEntities(array $params = []): Response
|
||||
{
|
||||
$response = $this->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;
|
||||
});
|
||||
}
|
||||
}
|
||||
34
src/Pinterest/Delete.php
Normal file
34
src/Pinterest/Delete.php
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Plug\Social\Pinterest;
|
||||
|
||||
use Core\Plug\Concern\BuildsResponse;
|
||||
use Core\Plug\Concern\ManagesTokens;
|
||||
use Core\Plug\Concern\UsesHttp;
|
||||
use Core\Plug\Contract\Deletable;
|
||||
use Core\Plug\Response;
|
||||
|
||||
/**
|
||||
* Pinterest pin deletion.
|
||||
*/
|
||||
class Delete implements Deletable
|
||||
{
|
||||
use BuildsResponse;
|
||||
use ManagesTokens;
|
||||
use UsesHttp;
|
||||
|
||||
private const API_URL = 'https://api.pinterest.com/v5';
|
||||
|
||||
public function delete(string $id): Response
|
||||
{
|
||||
$response = $this->http()
|
||||
->withToken($this->accessToken())
|
||||
->delete(self::API_URL."/pins/{$id}");
|
||||
|
||||
return $this->fromHttp($response, fn () => [
|
||||
'deleted' => true,
|
||||
]);
|
||||
}
|
||||
}
|
||||
35
src/Pinterest/Media.php
Normal file
35
src/Pinterest/Media.php
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Plug\Social\Pinterest;
|
||||
|
||||
use Core\Plug\Concern\BuildsResponse;
|
||||
use Core\Plug\Concern\ManagesTokens;
|
||||
use Core\Plug\Concern\UsesHttp;
|
||||
use Core\Plug\Contract\MediaUploadable;
|
||||
use Core\Plug\Response;
|
||||
|
||||
/**
|
||||
* Pinterest media upload.
|
||||
*/
|
||||
class Media implements MediaUploadable
|
||||
{
|
||||
use BuildsResponse;
|
||||
use ManagesTokens;
|
||||
use UsesHttp;
|
||||
|
||||
private const API_URL = 'https://api.pinterest.com/v5';
|
||||
|
||||
public function upload(array $item): Response
|
||||
{
|
||||
$response = $this->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'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
88
src/Pinterest/Post.php
Normal file
88
src/Pinterest/Post.php
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Plug\Social\Pinterest;
|
||||
|
||||
use Core\Plug\Concern\BuildsResponse;
|
||||
use Core\Plug\Concern\ManagesTokens;
|
||||
use Core\Plug\Concern\UsesHttp;
|
||||
use Core\Plug\Contract\Postable;
|
||||
use Core\Plug\Response;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
/**
|
||||
* Pinterest pin publishing.
|
||||
*/
|
||||
class Post implements Postable
|
||||
{
|
||||
use BuildsResponse;
|
||||
use ManagesTokens;
|
||||
use UsesHttp;
|
||||
|
||||
private const API_URL = 'https://api.pinterest.com/v5';
|
||||
|
||||
public function publish(string $text, Collection $media, array $params = []): Response
|
||||
{
|
||||
if ($media->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}";
|
||||
}
|
||||
}
|
||||
75
src/Pinterest/Read.php
Normal file
75
src/Pinterest/Read.php
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Plug\Social\Pinterest;
|
||||
|
||||
use Core\Plug\Concern\BuildsResponse;
|
||||
use Core\Plug\Concern\ManagesTokens;
|
||||
use Core\Plug\Concern\UsesHttp;
|
||||
use Core\Plug\Contract\Readable;
|
||||
use Core\Plug\Response;
|
||||
|
||||
/**
|
||||
* Pinterest account and pin reading.
|
||||
*/
|
||||
class Read implements Readable
|
||||
{
|
||||
use BuildsResponse;
|
||||
use ManagesTokens;
|
||||
use UsesHttp;
|
||||
|
||||
private const API_URL = 'https://api.pinterest.com/v5';
|
||||
|
||||
public function get(string $id): Response
|
||||
{
|
||||
$response = $this->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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
108
src/Reddit/Auth.php
Normal file
108
src/Reddit/Auth.php
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Plug\Social\Reddit;
|
||||
|
||||
use Core\Plug\Concern\BuildsResponse;
|
||||
use Core\Plug\Concern\ManagesTokens;
|
||||
use Core\Plug\Concern\UsesHttp;
|
||||
use Core\Plug\Contract\Authenticable;
|
||||
use Core\Plug\Contract\Refreshable;
|
||||
use Core\Plug\Response;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
/**
|
||||
* Reddit OAuth2 authentication.
|
||||
*
|
||||
* Uses Basic Auth for token requests with permanent duration.
|
||||
*/
|
||||
class Auth implements Authenticable, Refreshable
|
||||
{
|
||||
use BuildsResponse;
|
||||
use ManagesTokens;
|
||||
use UsesHttp;
|
||||
|
||||
private const AUTH_URL = 'https://www.reddit.com/api/v1';
|
||||
|
||||
private array $scope = ['identity', 'submit', 'read', 'history'];
|
||||
|
||||
public function __construct(
|
||||
private readonly string $clientId,
|
||||
private readonly string $clientSecret,
|
||||
private readonly string $redirectUrl,
|
||||
private readonly array $values = []
|
||||
) {}
|
||||
|
||||
public static function identifier(): string
|
||||
{
|
||||
return 'reddit';
|
||||
}
|
||||
|
||||
public static function name(): string
|
||||
{
|
||||
return 'Reddit';
|
||||
}
|
||||
|
||||
public function getAuthUrl(): string
|
||||
{
|
||||
$params = [
|
||||
'client_id' => $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');
|
||||
}
|
||||
}
|
||||
45
src/Reddit/Delete.php
Normal file
45
src/Reddit/Delete.php
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Plug\Social\Reddit;
|
||||
|
||||
use Core\Plug\Concern\BuildsResponse;
|
||||
use Core\Plug\Concern\ManagesTokens;
|
||||
use Core\Plug\Concern\UsesHttp;
|
||||
use Core\Plug\Contract\Deletable;
|
||||
use Core\Plug\Response;
|
||||
|
||||
/**
|
||||
* Reddit post deletion.
|
||||
*/
|
||||
class Delete implements Deletable
|
||||
{
|
||||
use BuildsResponse;
|
||||
use ManagesTokens;
|
||||
use UsesHttp;
|
||||
|
||||
private const API_URL = 'https://oauth.reddit.com';
|
||||
|
||||
protected function redditHeaders(): array
|
||||
{
|
||||
return [
|
||||
'User-Agent' => 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
75
src/Reddit/Media.php
Normal file
75
src/Reddit/Media.php
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Plug\Social\Reddit;
|
||||
|
||||
use Core\Plug\Concern\BuildsResponse;
|
||||
use Core\Plug\Concern\ManagesTokens;
|
||||
use Core\Plug\Concern\UsesHttp;
|
||||
use Core\Plug\Contract\MediaUploadable;
|
||||
use Core\Plug\Response;
|
||||
|
||||
/**
|
||||
* Reddit media upload.
|
||||
*
|
||||
* Two-step process: get S3 lease, upload to S3.
|
||||
*/
|
||||
class Media implements MediaUploadable
|
||||
{
|
||||
use BuildsResponse;
|
||||
use ManagesTokens;
|
||||
use UsesHttp;
|
||||
|
||||
private const API_URL = 'https://oauth.reddit.com';
|
||||
|
||||
protected function redditHeaders(): array
|
||||
{
|
||||
return [
|
||||
'User-Agent' => 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']}",
|
||||
]);
|
||||
}
|
||||
}
|
||||
105
src/Reddit/Post.php
Normal file
105
src/Reddit/Post.php
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Plug\Social\Reddit;
|
||||
|
||||
use Core\Plug\Concern\BuildsResponse;
|
||||
use Core\Plug\Concern\ManagesTokens;
|
||||
use Core\Plug\Concern\UsesHttp;
|
||||
use Core\Plug\Contract\Postable;
|
||||
use Core\Plug\Response;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
/**
|
||||
* Reddit post publishing.
|
||||
*
|
||||
* Supports text posts, link posts, and image posts.
|
||||
*/
|
||||
class Post implements Postable
|
||||
{
|
||||
use BuildsResponse;
|
||||
use ManagesTokens;
|
||||
use UsesHttp;
|
||||
|
||||
private const API_URL = 'https://oauth.reddit.com';
|
||||
|
||||
protected function redditHeaders(): array
|
||||
{
|
||||
return [
|
||||
'User-Agent' => 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}";
|
||||
}
|
||||
}
|
||||
99
src/Reddit/Read.php
Normal file
99
src/Reddit/Read.php
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Plug\Social\Reddit;
|
||||
|
||||
use Core\Plug\Concern\BuildsResponse;
|
||||
use Core\Plug\Concern\ManagesTokens;
|
||||
use Core\Plug\Concern\UsesHttp;
|
||||
use Core\Plug\Contract\Readable;
|
||||
use Core\Plug\Response;
|
||||
|
||||
/**
|
||||
* Reddit account and post reading.
|
||||
*/
|
||||
class Read implements Readable
|
||||
{
|
||||
use BuildsResponse;
|
||||
use ManagesTokens;
|
||||
use UsesHttp;
|
||||
|
||||
private const API_URL = 'https://oauth.reddit.com';
|
||||
|
||||
protected function redditHeaders(): array
|
||||
{
|
||||
return [
|
||||
'User-Agent' => 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
57
src/Reddit/Subreddits.php
Normal file
57
src/Reddit/Subreddits.php
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Plug\Social\Reddit;
|
||||
|
||||
use Core\Plug\Concern\BuildsResponse;
|
||||
use Core\Plug\Concern\ManagesTokens;
|
||||
use Core\Plug\Concern\UsesHttp;
|
||||
use Core\Plug\Contract\Listable;
|
||||
use Core\Plug\Response;
|
||||
|
||||
/**
|
||||
* Reddit subreddits listing.
|
||||
*/
|
||||
class Subreddits implements Listable
|
||||
{
|
||||
use BuildsResponse;
|
||||
use ManagesTokens;
|
||||
use UsesHttp;
|
||||
|
||||
private const API_URL = 'https://oauth.reddit.com';
|
||||
|
||||
protected function redditHeaders(): array
|
||||
{
|
||||
return [
|
||||
'User-Agent' => 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;
|
||||
});
|
||||
}
|
||||
}
|
||||
112
src/TikTok/Auth.php
Normal file
112
src/TikTok/Auth.php
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Plug\Social\TikTok;
|
||||
|
||||
use Core\Plug\Concern\BuildsResponse;
|
||||
use Core\Plug\Concern\ManagesTokens;
|
||||
use Core\Plug\Concern\UsesHttp;
|
||||
use Core\Plug\Contract\Authenticable;
|
||||
use Core\Plug\Contract\Refreshable;
|
||||
use Core\Plug\Response;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
/**
|
||||
* TikTok OAuth2 authentication.
|
||||
*
|
||||
* Uses client_key instead of client_id.
|
||||
*/
|
||||
class Auth implements Authenticable, Refreshable
|
||||
{
|
||||
use BuildsResponse;
|
||||
use ManagesTokens;
|
||||
use UsesHttp;
|
||||
|
||||
private const API_URL = 'https://open.tiktokapis.com/v2';
|
||||
|
||||
private const AUTH_URL = 'https://www.tiktok.com/v2/auth/authorize';
|
||||
|
||||
private array $scope = ['user.info.basic', 'video.publish', 'video.upload'];
|
||||
|
||||
public function __construct(
|
||||
private readonly string $clientId,
|
||||
private readonly string $clientSecret,
|
||||
private readonly string $redirectUrl,
|
||||
private readonly array $values = []
|
||||
) {}
|
||||
|
||||
public static function identifier(): string
|
||||
{
|
||||
return 'tiktok';
|
||||
}
|
||||
|
||||
public static function name(): string
|
||||
{
|
||||
return 'TikTok';
|
||||
}
|
||||
|
||||
public function getAuthUrl(): string
|
||||
{
|
||||
$params = [
|
||||
'client_key' => $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');
|
||||
}
|
||||
}
|
||||
98
src/TikTok/Post.php
Normal file
98
src/TikTok/Post.php
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Plug\Social\TikTok;
|
||||
|
||||
use Core\Plug\Concern\BuildsResponse;
|
||||
use Core\Plug\Concern\ManagesTokens;
|
||||
use Core\Plug\Concern\UsesHttp;
|
||||
use Core\Plug\Contract\Postable;
|
||||
use Core\Plug\Response;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
/**
|
||||
* TikTok video publishing.
|
||||
*
|
||||
* Multi-step process: initialise upload, upload video chunks.
|
||||
*/
|
||||
class Post implements Postable
|
||||
{
|
||||
use BuildsResponse;
|
||||
use ManagesTokens;
|
||||
use UsesHttp;
|
||||
|
||||
private const API_URL = 'https://open.tiktokapis.com/v2';
|
||||
|
||||
private const CHUNK_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
|
||||
public function publish(string $text, Collection $media, array $params = []): Response
|
||||
{
|
||||
if ($media->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}";
|
||||
}
|
||||
}
|
||||
58
src/TikTok/Read.php
Normal file
58
src/TikTok/Read.php
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Plug\Social\TikTok;
|
||||
|
||||
use Core\Plug\Concern\BuildsResponse;
|
||||
use Core\Plug\Concern\ManagesTokens;
|
||||
use Core\Plug\Concern\UsesHttp;
|
||||
use Core\Plug\Contract\Readable;
|
||||
use Core\Plug\Response;
|
||||
|
||||
/**
|
||||
* TikTok account reading.
|
||||
*/
|
||||
class Read implements Readable
|
||||
{
|
||||
use BuildsResponse;
|
||||
use ManagesTokens;
|
||||
use UsesHttp;
|
||||
|
||||
private const API_URL = 'https://open.tiktokapis.com/v2';
|
||||
|
||||
public function get(string $id): Response
|
||||
{
|
||||
// TikTok doesn't have a get-by-id endpoint for public videos
|
||||
return $this->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');
|
||||
}
|
||||
}
|
||||
168
src/Twitter/Auth.php
Normal file
168
src/Twitter/Auth.php
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Plug\Social\Twitter;
|
||||
|
||||
use Core\Plug\Concern\BuildsResponse;
|
||||
use Core\Plug\Concern\ManagesTokens;
|
||||
use Core\Plug\Concern\UsesHttp;
|
||||
use Core\Plug\Contract\Authenticable;
|
||||
use Core\Plug\Contract\Refreshable;
|
||||
use Core\Plug\Response;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Twitter/X OAuth2 PKCE authentication.
|
||||
*/
|
||||
class Auth implements Authenticable, Refreshable
|
||||
{
|
||||
use BuildsResponse;
|
||||
use ManagesTokens;
|
||||
use UsesHttp;
|
||||
|
||||
private const API_URL = 'https://api.twitter.com';
|
||||
|
||||
private const AUTH_URL = 'https://twitter.com/i/oauth2/authorize';
|
||||
|
||||
protected string $scope = 'tweet.read tweet.write users.read offline.access';
|
||||
|
||||
public function __construct(
|
||||
private readonly string $clientId,
|
||||
private readonly string $clientSecret,
|
||||
private readonly string $redirectUrl,
|
||||
private readonly ?string $state = null
|
||||
) {}
|
||||
|
||||
public static function identifier(): string
|
||||
{
|
||||
return 'twitter';
|
||||
}
|
||||
|
||||
public static function name(): string
|
||||
{
|
||||
return 'X';
|
||||
}
|
||||
|
||||
public function getAuthUrl(): string
|
||||
{
|
||||
$codeVerifier = $this->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}";
|
||||
}
|
||||
}
|
||||
34
src/Twitter/Delete.php
Normal file
34
src/Twitter/Delete.php
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Plug\Social\Twitter;
|
||||
|
||||
use Core\Plug\Concern\BuildsResponse;
|
||||
use Core\Plug\Concern\ManagesTokens;
|
||||
use Core\Plug\Concern\UsesHttp;
|
||||
use Core\Plug\Contract\Deletable;
|
||||
use Core\Plug\Response;
|
||||
|
||||
/**
|
||||
* Twitter/X post deletion.
|
||||
*/
|
||||
class Delete implements Deletable
|
||||
{
|
||||
use BuildsResponse;
|
||||
use ManagesTokens;
|
||||
use UsesHttp;
|
||||
|
||||
private const API_URL = 'https://api.twitter.com';
|
||||
|
||||
public function delete(string $id): Response
|
||||
{
|
||||
$response = $this->http()
|
||||
->withToken($this->accessToken())
|
||||
->delete(self::API_URL."/2/tweets/{$id}");
|
||||
|
||||
return $this->fromHttp($response, fn ($data) => [
|
||||
'deleted' => $data['data']['deleted'] ?? false,
|
||||
]);
|
||||
}
|
||||
}
|
||||
119
src/Twitter/Media.php
Normal file
119
src/Twitter/Media.php
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Plug\Social\Twitter;
|
||||
|
||||
use Core\Plug\Concern\BuildsResponse;
|
||||
use Core\Plug\Concern\ManagesTokens;
|
||||
use Core\Plug\Concern\UsesHttp;
|
||||
use Core\Plug\Contract\MediaUploadable;
|
||||
use Core\Plug\Response;
|
||||
|
||||
/**
|
||||
* Twitter/X media upload.
|
||||
*
|
||||
* Supports both simple upload and chunked upload for large files.
|
||||
*/
|
||||
class Media implements MediaUploadable
|
||||
{
|
||||
use BuildsResponse;
|
||||
use ManagesTokens;
|
||||
use UsesHttp;
|
||||
|
||||
private const UPLOAD_URL = 'https://upload.twitter.com';
|
||||
|
||||
private const CHUNK_SIZE = 5 * 1024 * 1024; // 5MB
|
||||
|
||||
public function upload(array $item): Response
|
||||
{
|
||||
$filePath = $item['path'];
|
||||
$fileSize = filesize($filePath);
|
||||
|
||||
// Use chunked upload for files > 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'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
76
src/Twitter/Post.php
Normal file
76
src/Twitter/Post.php
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Plug\Social\Twitter;
|
||||
|
||||
use Core\Plug\Concern\BuildsResponse;
|
||||
use Core\Plug\Concern\ManagesTokens;
|
||||
use Core\Plug\Concern\UsesHttp;
|
||||
use Core\Plug\Contract\Postable;
|
||||
use Core\Plug\Response;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
/**
|
||||
* Twitter/X post publishing.
|
||||
*/
|
||||
class Post implements Postable
|
||||
{
|
||||
use BuildsResponse;
|
||||
use ManagesTokens;
|
||||
use UsesHttp;
|
||||
|
||||
private const API_URL = 'https://api.twitter.com';
|
||||
|
||||
public function publish(string $text, Collection $media, array $params = []): Response
|
||||
{
|
||||
$tweetData = ['text' => $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}";
|
||||
}
|
||||
}
|
||||
70
src/Twitter/Read.php
Normal file
70
src/Twitter/Read.php
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Plug\Social\Twitter;
|
||||
|
||||
use Core\Plug\Concern\BuildsResponse;
|
||||
use Core\Plug\Concern\ManagesTokens;
|
||||
use Core\Plug\Concern\UsesHttp;
|
||||
use Core\Plug\Contract\Readable;
|
||||
use Core\Plug\Response;
|
||||
|
||||
/**
|
||||
* Twitter/X post reading.
|
||||
*/
|
||||
class Read implements Readable
|
||||
{
|
||||
use BuildsResponse;
|
||||
use ManagesTokens;
|
||||
use UsesHttp;
|
||||
|
||||
private const API_URL = 'https://api.twitter.com';
|
||||
|
||||
public function get(string $id): Response
|
||||
{
|
||||
$response = $this->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'] ?? [],
|
||||
]);
|
||||
}
|
||||
}
|
||||
91
src/VK/Auth.php
Normal file
91
src/VK/Auth.php
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Plug\Social\VK;
|
||||
|
||||
use Core\Plug\Concern\BuildsResponse;
|
||||
use Core\Plug\Concern\UsesHttp;
|
||||
use Core\Plug\Contract\Authenticable;
|
||||
use Core\Plug\Response;
|
||||
|
||||
/**
|
||||
* VK OAuth2 authentication.
|
||||
*
|
||||
* Uses offline scope for long-lived tokens (no refresh needed).
|
||||
*/
|
||||
class Auth implements Authenticable
|
||||
{
|
||||
use BuildsResponse;
|
||||
use UsesHttp;
|
||||
|
||||
private const API_VERSION = '5.199';
|
||||
|
||||
private array $scope = ['wall', 'groups', 'photos', 'video', 'offline'];
|
||||
|
||||
public function __construct(
|
||||
private readonly string $clientId,
|
||||
private readonly string $clientSecret,
|
||||
private readonly string $redirectUrl,
|
||||
private readonly array $values = []
|
||||
) {}
|
||||
|
||||
public static function identifier(): string
|
||||
{
|
||||
return 'vk';
|
||||
}
|
||||
|
||||
public static function name(): string
|
||||
{
|
||||
return 'VK';
|
||||
}
|
||||
|
||||
public function getAuthUrl(): string
|
||||
{
|
||||
$params = [
|
||||
'client_id' => $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');
|
||||
}
|
||||
}
|
||||
59
src/VK/Delete.php
Normal file
59
src/VK/Delete.php
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Plug\Social\VK;
|
||||
|
||||
use Core\Plug\Concern\BuildsResponse;
|
||||
use Core\Plug\Concern\ManagesTokens;
|
||||
use Core\Plug\Concern\UsesHttp;
|
||||
use Core\Plug\Contract\Deletable;
|
||||
use Core\Plug\Response;
|
||||
|
||||
/**
|
||||
* VK wall post deletion.
|
||||
*/
|
||||
class Delete implements Deletable
|
||||
{
|
||||
use BuildsResponse;
|
||||
use ManagesTokens;
|
||||
use UsesHttp;
|
||||
|
||||
private const API_URL = 'https://api.vk.com/method';
|
||||
|
||||
private const API_VERSION = '5.199';
|
||||
|
||||
private ?string $ownerId = null;
|
||||
|
||||
/**
|
||||
* Set the owner context for deletion.
|
||||
*/
|
||||
public function forOwner(string $ownerId): static
|
||||
{
|
||||
$this->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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
59
src/VK/Groups.php
Normal file
59
src/VK/Groups.php
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Plug\Social\VK;
|
||||
|
||||
use Core\Plug\Concern\BuildsResponse;
|
||||
use Core\Plug\Concern\ManagesTokens;
|
||||
use Core\Plug\Concern\UsesHttp;
|
||||
use Core\Plug\Contract\Listable;
|
||||
use Core\Plug\Response;
|
||||
|
||||
/**
|
||||
* VK groups listing.
|
||||
*/
|
||||
class Groups implements Listable
|
||||
{
|
||||
use BuildsResponse;
|
||||
use ManagesTokens;
|
||||
use UsesHttp;
|
||||
|
||||
private const API_URL = 'https://api.vk.com/method';
|
||||
|
||||
private const API_VERSION = '5.199';
|
||||
|
||||
public function listEntities(array $params = []): Response
|
||||
{
|
||||
$requestParams = [
|
||||
'extended' => 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);
|
||||
}
|
||||
}
|
||||
116
src/VK/Media.php
Normal file
116
src/VK/Media.php
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Plug\Social\VK;
|
||||
|
||||
use Core\Plug\Concern\BuildsResponse;
|
||||
use Core\Plug\Concern\ManagesTokens;
|
||||
use Core\Plug\Concern\UsesHttp;
|
||||
use Core\Plug\Contract\MediaUploadable;
|
||||
use Core\Plug\Response;
|
||||
|
||||
/**
|
||||
* VK media upload.
|
||||
*
|
||||
* Two-step process: get upload server, upload to server, save photo.
|
||||
*/
|
||||
class Media implements MediaUploadable
|
||||
{
|
||||
use BuildsResponse;
|
||||
use ManagesTokens;
|
||||
use UsesHttp;
|
||||
|
||||
private const API_URL = 'https://api.vk.com/method';
|
||||
|
||||
private const API_VERSION = '5.199';
|
||||
|
||||
private ?int $groupId = null;
|
||||
|
||||
/**
|
||||
* Set the group context for uploads.
|
||||
*/
|
||||
public function forGroup(int $groupId): static
|
||||
{
|
||||
$this->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'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
129
src/VK/Post.php
Normal file
129
src/VK/Post.php
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Plug\Social\VK;
|
||||
|
||||
use Core\Plug\Concern\BuildsResponse;
|
||||
use Core\Plug\Concern\ManagesTokens;
|
||||
use Core\Plug\Concern\UsesHttp;
|
||||
use Core\Plug\Contract\Postable;
|
||||
use Core\Plug\Response;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
/**
|
||||
* VK wall post publishing.
|
||||
*/
|
||||
class Post implements Postable
|
||||
{
|
||||
use BuildsResponse;
|
||||
use ManagesTokens;
|
||||
use UsesHttp;
|
||||
|
||||
private const API_URL = 'https://api.vk.com/method';
|
||||
|
||||
private const API_VERSION = '5.199';
|
||||
|
||||
public function publish(string $text, Collection $media, array $params = []): Response
|
||||
{
|
||||
$groupId = $params['group_id'] ?? null;
|
||||
$ownerId = $groupId ? "-{$groupId}" : ($this->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}";
|
||||
}
|
||||
}
|
||||
117
src/VK/Read.php
Normal file
117
src/VK/Read.php
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Plug\Social\VK;
|
||||
|
||||
use Core\Plug\Concern\BuildsResponse;
|
||||
use Core\Plug\Concern\ManagesTokens;
|
||||
use Core\Plug\Concern\UsesHttp;
|
||||
use Core\Plug\Contract\Readable;
|
||||
use Core\Plug\Response;
|
||||
|
||||
/**
|
||||
* VK account and post reading.
|
||||
*/
|
||||
class Read implements Readable
|
||||
{
|
||||
use BuildsResponse;
|
||||
use ManagesTokens;
|
||||
use UsesHttp;
|
||||
|
||||
private const API_URL = 'https://api.vk.com/method';
|
||||
|
||||
private const API_VERSION = '5.199';
|
||||
|
||||
public function get(string $id): Response
|
||||
{
|
||||
$response = $this->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() ?? [];
|
||||
}
|
||||
}
|
||||
117
src/YouTube/Auth.php
Normal file
117
src/YouTube/Auth.php
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Plug\Social\YouTube;
|
||||
|
||||
use Core\Plug\Concern\BuildsResponse;
|
||||
use Core\Plug\Concern\ManagesTokens;
|
||||
use Core\Plug\Concern\UsesHttp;
|
||||
use Core\Plug\Contract\Authenticable;
|
||||
use Core\Plug\Contract\Refreshable;
|
||||
use Core\Plug\Response;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
/**
|
||||
* YouTube (Google) OAuth2 authentication.
|
||||
*
|
||||
* Uses Google's OAuth2 with offline access for refresh tokens.
|
||||
*/
|
||||
class Auth implements Authenticable, Refreshable
|
||||
{
|
||||
use BuildsResponse;
|
||||
use ManagesTokens;
|
||||
use UsesHttp;
|
||||
|
||||
private const AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth';
|
||||
|
||||
private const TOKEN_URL = 'https://oauth2.googleapis.com/token';
|
||||
|
||||
private array $scope = [
|
||||
'https://www.googleapis.com/auth/youtube.upload',
|
||||
'https://www.googleapis.com/auth/youtube',
|
||||
'https://www.googleapis.com/auth/youtube.readonly',
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private readonly string $clientId,
|
||||
private readonly string $clientSecret,
|
||||
private readonly string $redirectUrl,
|
||||
private readonly array $values = []
|
||||
) {}
|
||||
|
||||
public static function identifier(): string
|
||||
{
|
||||
return 'youtube';
|
||||
}
|
||||
|
||||
public static function name(): string
|
||||
{
|
||||
return 'YouTube';
|
||||
}
|
||||
|
||||
public function getAuthUrl(): string
|
||||
{
|
||||
$params = [
|
||||
'client_id' => $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');
|
||||
}
|
||||
}
|
||||
43
src/YouTube/Comment.php
Normal file
43
src/YouTube/Comment.php
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Plug\Social\YouTube;
|
||||
|
||||
use Core\Plug\Concern\BuildsResponse;
|
||||
use Core\Plug\Concern\ManagesTokens;
|
||||
use Core\Plug\Concern\UsesHttp;
|
||||
use Core\Plug\Contract\Commentable;
|
||||
use Core\Plug\Response;
|
||||
|
||||
/**
|
||||
* YouTube video commenting.
|
||||
*/
|
||||
class Comment implements Commentable
|
||||
{
|
||||
use BuildsResponse;
|
||||
use ManagesTokens;
|
||||
use UsesHttp;
|
||||
|
||||
private const API_URL = 'https://www.googleapis.com/youtube/v3';
|
||||
|
||||
public function comment(string $text, string $postId, array $params = []): Response
|
||||
{
|
||||
$response = $this->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'] ?? '',
|
||||
]);
|
||||
}
|
||||
}
|
||||
36
src/YouTube/Delete.php
Normal file
36
src/YouTube/Delete.php
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Plug\Social\YouTube;
|
||||
|
||||
use Core\Plug\Concern\BuildsResponse;
|
||||
use Core\Plug\Concern\ManagesTokens;
|
||||
use Core\Plug\Concern\UsesHttp;
|
||||
use Core\Plug\Contract\Deletable;
|
||||
use Core\Plug\Response;
|
||||
|
||||
/**
|
||||
* YouTube video deletion.
|
||||
*/
|
||||
class Delete implements Deletable
|
||||
{
|
||||
use BuildsResponse;
|
||||
use ManagesTokens;
|
||||
use UsesHttp;
|
||||
|
||||
private const API_URL = 'https://www.googleapis.com/youtube/v3';
|
||||
|
||||
public function delete(string $id): Response
|
||||
{
|
||||
$response = $this->http()
|
||||
->withToken($this->accessToken())
|
||||
->delete(self::API_URL.'/videos', [
|
||||
'id' => $id,
|
||||
]);
|
||||
|
||||
return $this->fromHttp($response, fn () => [
|
||||
'deleted' => true,
|
||||
]);
|
||||
}
|
||||
}
|
||||
107
src/YouTube/Post.php
Normal file
107
src/YouTube/Post.php
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Plug\Social\YouTube;
|
||||
|
||||
use Core\Plug\Concern\BuildsResponse;
|
||||
use Core\Plug\Concern\ManagesTokens;
|
||||
use Core\Plug\Concern\UsesHttp;
|
||||
use Core\Plug\Contract\Postable;
|
||||
use Core\Plug\Response;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
/**
|
||||
* YouTube video publishing.
|
||||
*
|
||||
* Uses resumable upload for video content.
|
||||
*/
|
||||
class Post implements Postable
|
||||
{
|
||||
use BuildsResponse;
|
||||
use ManagesTokens;
|
||||
use UsesHttp;
|
||||
|
||||
private const API_URL = 'https://www.googleapis.com/youtube/v3';
|
||||
|
||||
private const UPLOAD_URL = 'https://www.googleapis.com/upload/youtube/v3';
|
||||
|
||||
public function publish(string $text, Collection $media, array $params = []): Response
|
||||
{
|
||||
if ($media->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}";
|
||||
}
|
||||
}
|
||||
101
src/YouTube/Read.php
Normal file
101
src/YouTube/Read.php
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Plug\Social\YouTube;
|
||||
|
||||
use Core\Plug\Concern\BuildsResponse;
|
||||
use Core\Plug\Concern\ManagesTokens;
|
||||
use Core\Plug\Concern\UsesHttp;
|
||||
use Core\Plug\Contract\Readable;
|
||||
use Core\Plug\Response;
|
||||
|
||||
/**
|
||||
* YouTube channel and video reading.
|
||||
*/
|
||||
class Read implements Readable
|
||||
{
|
||||
use BuildsResponse;
|
||||
use ManagesTokens;
|
||||
use UsesHttp;
|
||||
|
||||
private const API_URL = 'https://www.googleapis.com/youtube/v3';
|
||||
|
||||
public function get(string $id): Response
|
||||
{
|
||||
$response = $this->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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue