123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445 |
- <?php
- namespace App\Services\Smm;
- use App\Services\Contracts\SmmPlatformInterface;
- use Abraham\TwitterOAuth\TwitterOAuth;
- use Carbon\Carbon;
- use CURLFile;
- use Illuminate\Http\Request;
- use Illuminate\Support\Facades\Log;
- class TwitterService implements SmmPlatformInterface
- {
- protected $configData;
- private $clientId;
- private $clientSecret;
- private $access_token;
- private $media_upload_url = 'https://api.x.com/2/media/upload';
- private $tweet_url = 'https://api.x.com/2/tweets';
- private $chunk_size = 4 * 1024 * 1024; // 分块上传的大小,不能大于5M,否则会报错
- public function __construct($configData)
- {
- $this->clientId = env('SSM_X_CLIENT_ID');
- $this->clientSecret = env('SSM_X_CLIENT_SECRET');
- $this->configData = $configData;
- }
- public function login()
- {
- $redirectUri = env('DIST_SITE_URL') . '/dist/callback/twitter';
- $scopes = ['tweet.read','tweet.write','dm.write', 'users.read', 'offline.access','media.write']; // 需要的权限范围
- $oauthState = bin2hex(random_bytes(16));
- $authUrl = 'https://twitter.com/i/oauth2/authorize?' . http_build_query([
- 'response_type' => 'code',
- 'client_id' => $this->clientId,
- 'redirect_uri' => $redirectUri,
- 'scope' => implode(' ', $scopes),
- 'state' => $oauthState,
- 'code_challenge' => 'challenge', // 如果使用 PKCE 需要生成
- 'code_challenge_method' => 'plain'
- ]);
- return [
- 'status' => true,
- 'data' => ['url' => $authUrl]
- ];
- }
- public function loginCallback(Request $request)
- {
- try {
- $tokenUrl = 'https://api.twitter.com/2/oauth2/token';
- $redirectUri = env('DIST_SITE_URL') . '/dist/callback/twitter';
- $params = [
- 'code' => $_GET['code'],
- 'grant_type' => 'authorization_code',
- 'client_id' => $this->clientId,
- 'redirect_uri' => $redirectUri,
- 'code_verifier' => 'challenge' // 如果使用 PKCE 需要对应
- ];
- $ch = curl_init();
- curl_setopt($ch, CURLOPT_URL, $tokenUrl);
- curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
- curl_setopt($ch, CURLOPT_POST, true);
- curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($params));
- curl_setopt($ch, CURLOPT_HTTPHEADER, [
- 'Authorization: Basic ' . base64_encode($this->clientId . ':' . $this->clientSecret),
- 'Content-Type: application/x-www-form-urlencoded'
- ]);
- $auth = curl_exec($ch);
- curl_close($ch);
- $auth = json_decode($auth, true);
- if (isset($auth['access_token']) == false) {
- return ['status' => false, 'data' => '获取token失败'];
- }
- $this->access_token = $auth['access_token'];
- $url = 'https://api.twitter.com/2/users/me?user.fields=id,username,name';
- $result = $this->twitterApiRequest($url, 'GET');
- // var_dump($result);
- // exit;
- if ($result['code'] != 200) {
- return ['status' => false, 'data' => '获取token失败'];
- }
- if (isset($auth['access_token'])) {
- $userData = $result['response']['data'];
- $userId = $userData['id'];
- $username = $userData['username'];
- $name = $userData['name'];
- $refresh_token = $auth['refresh_token'] ?? '';
- $expires_in = $auth['expires_in'] ?? 0;
- $expiresAt = Carbon::now()->addSeconds($expires_in)->format('Y-m-d H:i:s');
- return [
- 'status' => true,
- 'data' => [
- 'accessToken' => $auth['access_token'],
- 'backupField1' => '',
- 'userId' => $userId,
- 'userName' => $username,
- 'accessToken_expiresAt' => $expiresAt,
- 'refresh_token' => $refresh_token,
- ]
- ];
- } else {
- return ['status' => false, 'data' => '获取token失败'];
- }
- } catch (\Exception $e) {
- return ['status' => false, 'data' => $e->getMessage()];
- }
- }
- public function postImage($message, $imagePaths, $accessToken = null)
- {
- try {
- $this->access_token = $accessToken;
- $videoPaths = [];
- foreach ($imagePaths as $imagePath) {
- $videoPaths[] = toStoragePath($imagePath);//用本地路径上传
- }
- $mediaResult = $this->uploadAndPost($videoPaths, $message);
- if (isset($mediaResult['response']['data']['id'])) {
- return ['status' => true,
- 'data' => [
- 'responseIds' => [$mediaResult['response']['data']['id']],
- ]
- ];
- } else {
- return ['status' => false, 'data' => '发布失败'];
- }
- } catch (\Exception $e) {
- return ['status' => false, 'data' => $e->getMessage()];
- }
- }
- public function postVideo($message, $videoPath, $accessToken = null)
- {
- try {
- $this->access_token = $accessToken;
- $videoPath = toStoragePath($videoPath);//用本地路径上传
- $mediaResult = $this->uploadAndPost([$videoPath], $message);
- if (isset($mediaResult['response']['data']['id'])) {
- return ['status' => true,
- 'data' => [
- 'responseIds' => [$mediaResult['response']['data']['id']],
- ]
- ];
- } else {
- return ['status' => false, 'data' => '上传视频失败'];
- }
- } catch (\Exception $e) {
- return ['status' => false, 'data' => $e->getMessage()];
- }
- }
- // 保持空实现
- public function getComments($postId) {}
- public function replyToComment($commentId) {}
- public function deleteComment($commentId) {}
- /**
- * 通用Twitter API请求
- */
- private function twitterApiRequest($url, $method = 'POST', $data = [], $headers = []) {
- try {
- Log::info('curl 请求:'.$url);
- $ch = curl_init();
- $headers = array_merge([
- 'Authorization: Bearer ' . $this->access_token
- ], $headers);
- Log::info('curl $headers:'.json_encode($headers));
- curl_setopt_array($ch, [
- CURLOPT_URL => $url,
- CURLOPT_RETURNTRANSFER => true,
- CURLOPT_FOLLOWLOCATION => true,
- CURLOPT_HTTPHEADER => $headers,
- CURLOPT_TIMEOUT => 50,
- ]);
- if ($method === 'POST') {
- curl_setopt($ch, CURLOPT_POST, true);
- // 处理JSON数据
- curl_setopt($ch, CURLOPT_POSTFIELDS, $data);
- }
- $response = curl_exec($ch);
- $error = curl_error($ch);
- curl_close($ch);
- Log::info('curl response:'.$response);
- if ($error) {
- throw new \Exception("cURL Error: ".$error);
- }
- if ($response === false) {
- throw new \Exception("cURL Error: false response");
- }
- $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
- return ['code'=>$http_code,'response' => json_decode($response, true)];
- } catch (\Exception $e) {
- throw new \Exception("catch cURL Error: ".$e->getMessage());
- }
- }
- /**
- * 初始化上传
- */
- public function initUpload($fileSize, $mediaType = 'video/mp4',$media_category = 'tweet_video') {
- return $this->twitterApiRequest($this->media_upload_url, 'POST', [
- 'command' => 'INIT',
- 'media_type' => $mediaType,
- 'total_bytes' => $fileSize,
- 'media_category' => $media_category
- ]);
- }
- /**
- * 分块上传
- */
- public function appendUpload($media_id, $filePath) {
- $handle = fopen($filePath, 'rb');
- $segment_index = 0;
- while (!feof($handle)) {
- $chunk = fread($handle, $this->chunk_size);
- $result = $this->twitterApiRequest($this->media_upload_url, 'POST', [
- 'command' => 'APPEND',
- 'media_id' => $media_id,
- 'segment_index' => $segment_index,
- 'media' => $chunk
- ]);
- Log::info('分块上传'.$segment_index.' : '.json_encode($result));
- if ($result['code'] !== 204) {
- return false;
- }
- $segment_index++;
- sleep(2); // 避免速率限制
- }
- fclose($handle);
- return true;
- }
- /**
- * 完成上传
- */
- public function finalizeUpload($media_id) {
- return $this->twitterApiRequest($this->media_upload_url, 'POST', [
- 'command' => 'FINALIZE',
- 'media_id' => $media_id
- ]);
- }
- /**
- * 检查媒体状态
- */
- public function checkStatus($media_id) {
- $url = $this->media_upload_url . '?' . http_build_query([
- 'command' => 'STATUS',
- 'media_id' => $media_id
- ]);
- return $this->twitterApiRequest($url, 'GET');
- }
- /**
- * 等待媒体处理完成
- */
- public function waitForProcessing($media_id, $interval = 10, $max_attempts = 6) {
- $attempts = 0;
- do {
- $status = $this->checkStatus($media_id);
- Log::info('等待媒体处理完成:'.$attempts.' : '.json_encode($status));
- if (!isset($status['response']['data'])) {
- throw new \Exception("waitForProcessing failed: status data not found");
- }
- $state = $status['response']['data']['processing_info']['state'] ?? '';
- if ($state === 'succeeded') return true;
- if ($state === 'failed') return false;
- sleep($interval);
- $attempts++;
- } while ($attempts < $max_attempts);
- throw new \Exception("Media processing timeout");
- }
- /**
- * 发布推文
- */
- public function postTweet($text, $media_ids) {
- $data = [
- 'text' => $text,
- 'media' => ['media_ids' => $media_ids]
- ];
- $data = json_encode($data);
- return $this->twitterApiRequest($this->tweet_url, 'POST',$data , ['Content-Type: application/json']);
- }
- /**
- * 完整上传流程
- */
- public function uploadAndPost($filePaths, $tweetText) {
- try {
- $media_ids = [];
- foreach ($filePaths as $filePath) {
- $file_info = pathinfo($filePath);
- $file_extension = $file_info['extension'];
- if ($file_extension == 'mp4' && filesize($filePath) > 20 * 1024 * 1024) {
- //不能大于20M
- return ['status'=>false,'message' => 'File size too large (max 20M)'];
- } else if (($file_extension == 'jpg' || $file_extension == 'png') && filesize($filePath) > 5 * 1024 * 1024) {
- //不能大于5M
- return ['status'=>false,'message' => 'File size too large (max 5M)'];
- }
- if ($file_extension == 'mp4') {
- $media_category = 'tweet_video';
- $mediaType = 'video/mp4';
- } else if ($file_extension == 'jpg') {
- $media_category = 'tweet_image';
- $mediaType = 'image/jpeg';
- } else {
- $media_category = 'tweet_image';
- $mediaType = 'image/png';
- }
- Log::info('Twitter开始发布帖子,文件格式:'.$file_extension);
- // 1. 初始化上传
- $init = $this->initUpload(filesize($filePath),$mediaType,$media_category);
- $media_id = isset($init['response']['data']['id']) ? $init['response']['data']['id'] : null;
- $media_ids[] = $media_id;
- if (!$media_id) {
- return ['status'=>false,'message' => 'Init upload failed'];
- }
- Log::info('初始化上传成功'.$media_id);
- // 2. 分块上传
- $append = $this->appendUpload($media_id, $filePath);
- if (!$append) {
- return ['status'=>false,'message' => 'Append upload failed'];
- }
- // 3. 完成上传
- $finalize = $this->finalizeUpload($media_id);
- Log::info('完成上传:'.json_encode($finalize));
- if (isset($finalize['response']['data']) == false) {
- return ['status'=>false,'message' => 'Finalize upload failed'];
- }
- // 4. mp4 等待处理完成
- if ($file_extension == 'mp4') {
- Log::info('等待处理完成');
- if (!$this->waitForProcessing($media_id)) {
- throw new \Exception('Media processing failed');
- }
- }
- }
- // 5. 发布推文
- Log::info('发布推文');
- $postTweet = $this->postTweet($tweetText, $media_ids);
- Log::info('发布推文结果:'.json_encode($postTweet));
- return $postTweet;
- } catch (\Exception $e) {
- return ['status'=>false,'message' => "Upload failed: ".$e->getMessage()];
- }
- }
- public function refreshAccessToken($refreshToken)
- {
- try {
- $tokenUrl = 'https://api.twitter.com/2/oauth2/token';
- $params = [
- 'refresh_token' => $refreshToken,
- 'grant_type' => 'refresh_token',
- 'client_id' => $this->clientId,
- ];
- $ch = curl_init();
- curl_setopt($ch, CURLOPT_URL, $tokenUrl);
- curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
- curl_setopt($ch, CURLOPT_POST, true);
- curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($params));
- curl_setopt($ch, CURLOPT_HTTPHEADER, [
- 'Authorization: Basic ' . base64_encode($this->clientId . ':' . $this->clientSecret),
- 'Content-Type: application/x-www-form-urlencoded'
- ]);
- $response = curl_exec($ch);
- $error = curl_error($ch);
- $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
- curl_close($ch);
- if ($error) {
- throw new \Exception("cURL Error: " . $error);
- }
- $authData = json_decode($response, true);
- if (!$authData || isset($authData['error'])) {
- throw new \Exception("Token refresh failed: " . ($authData['error_description'] ?? 'Unknown error'));
- }
- if (!isset($authData['access_token'])) {
- throw new \Exception("Access token not found in response");
- }
- $newAccessToken = $authData['access_token'];
- $newRefreshToken = $authData['refresh_token'] ?? $refreshToken; // 使用新的refresh_token,若未返回则保留原值
- $expiresIn = $authData['expires_in'] ?? 0;
- $expiresAt = Carbon::now()->addSeconds($expiresIn)->format('Y-m-d H:i:s');
- return [
- 'status' => true,
- 'data' => [
- 'access_token' => $newAccessToken,
- 'refresh_token' => $newRefreshToken,
- 'expires_at' => $expiresAt,
- ],
- ];
- } catch (\Exception $e) {
- Log::error("Twitter刷新Token失败: " . $e->getMessage());
- return [
- 'status' => false,
- 'data' => $e->getMessage(),
- ];
- }
- }
- }
|