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:
Snider 2026-03-09 17:27:54 +00:00
parent 84e7ecfc9b
commit 7b2b7968d3
44 changed files with 3594 additions and 0 deletions

17
composer.json Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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,
]);
}
}