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' => json_encode(['refresh_token'=>$refresh_token]), 'userId' => $userId, 'userName' => $username, 'accessToken_expiresAt' => $expiresAt, ] ]; } 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()]; } } }