From 831223f7bc4de71672adade0a389394c5df51547 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Sat, 27 Jun 2026 20:48:22 -0500 Subject: [PATCH] feat: add Twitter thread support and cost warning (#163) Authored-by: Moko Consulting --- CHANGELOG.md | 3 + .../en-GB/plg_mokosuitecross_twitter.ini | 1 + .../src/Extension/TwitterService.php | 240 +++++++++++++++--- 3 files changed, 208 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e4bf1b8d..0fb9555a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## [Unreleased] ### Added +- **X/Twitter threads**: Auto-split messages exceeding 280 chars into reply chains at sentence boundaries +- **X/Twitter cost-optimized posting**: Optional mode to post text-only tweet first ($0.015) with URL as separate reply ($0.20) +- **X/Twitter cost warning**: Language string documenting X API pricing for text vs URL posts - **Instagram carousel**: Multi-image/video posts via Meta carousel container flow (up to 10 items) - **Instagram Reels**: Short-form video publishing via REELS media type - **Instagram Stories**: Image and video story publishing via STORIES media type diff --git a/source/packages/plg_mokosuitecross_twitter/language/en-GB/plg_mokosuitecross_twitter.ini b/source/packages/plg_mokosuitecross_twitter/language/en-GB/plg_mokosuitecross_twitter.ini index b5163e7c..f81c16a4 100644 --- a/source/packages/plg_mokosuitecross_twitter/language/en-GB/plg_mokosuitecross_twitter.ini +++ b/source/packages/plg_mokosuitecross_twitter/language/en-GB/plg_mokosuitecross_twitter.ini @@ -1,2 +1,3 @@ PLG_MOKOSUITECROSS_TWITTER="MokoSuiteCross - X / Twitter" PLG_MOKOSUITECROSS_TWITTER_DESCRIPTION="Cross-post Joomla articles to X / Twitter." +PLG_MOKOSUITECROSS_TWITTER_COST_WARNING="X API Pricing: Text-only posts cost $0.015 each. Posts containing URLs cost $0.20 each. Cross-posting articles with links will incur URL post charges." diff --git a/source/packages/plg_mokosuitecross_twitter/src/Extension/TwitterService.php b/source/packages/plg_mokosuitecross_twitter/src/Extension/TwitterService.php index 8d9d2abf..e951fc68 100644 --- a/source/packages/plg_mokosuitecross_twitter/src/Extension/TwitterService.php +++ b/source/packages/plg_mokosuitecross_twitter/src/Extension/TwitterService.php @@ -51,9 +51,6 @@ class TwitterService extends CMSPlugin implements SubscriberInterface, MokoSuite public function publish(string $message, array $media, array $credentials, array $params): array { - $apiUrl = 'https://api.twitter.com/2/tweets'; - $postData = json_encode(['text' => mb_substr($message, 0, 280)]); - $consumerKey = $credentials['api_key'] ?? ''; $consumerSecret = $credentials['api_secret'] ?? ''; $accessToken = $credentials['access_token'] ?? ''; @@ -67,41 +64,17 @@ class TwitterService extends CMSPlugin implements SubscriberInterface, MokoSuite ]; } - $authHeader = $this->buildOAuth1Header('POST', $apiUrl, $consumerKey, $consumerSecret, $accessToken, $tokenSecret); - - $ch = curl_init($apiUrl); - curl_setopt_array($ch, [ - CURLOPT_POST => true, - CURLOPT_POSTFIELDS => $postData, - CURLOPT_HTTPHEADER => [ - 'Content-Type: application/json', - 'Authorization: ' . $authHeader, - ], - CURLOPT_RETURNTRANSFER => true, - CURLOPT_TIMEOUT => 30, - ]); - - $response = curl_exec($ch); - - if ($response === false) { - - $curlError = curl_error($ch); - - curl_close($ch); - - return ['success' => false, 'platform_post_id' => '', 'response' => ['error' => 'Connection error: ' . $curlError]]; - - } - $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); - curl_close($ch); - - $data = json_decode($response, true) ?: []; - - if ($httpCode === 201 && !empty($data['data']['id'])) { - return ['success' => true, 'platform_post_id' => $data['data']['id'], 'response' => $data]; + if (!empty($params['cost_optimize'])) { + return $this->publishCostOptimized($message, $credentials, $consumerKey, $consumerSecret, $accessToken, $tokenSecret); } - return ['success' => false, 'platform_post_id' => '', 'response' => $data]; + $chunks = $this->splitIntoThread($message); + + if (\count($chunks) === 1) { + return $this->postTweet($chunks[0], null, $consumerKey, $consumerSecret, $accessToken, $tokenSecret); + } + + return $this->postThread($chunks, $consumerKey, $consumerSecret, $accessToken, $tokenSecret); } public function validateCredentials(array $credentials): array @@ -158,6 +131,201 @@ class TwitterService extends CMSPlugin implements SubscriberInterface, MokoSuite return true; } + private function splitIntoThread(string $message, int $maxLen = 280): array + { + if (mb_strlen($message) <= $maxLen) { + return [$message]; + } + + $chunks = []; + + while (mb_strlen($message) > $maxLen) { + $segment = mb_substr($message, 0, $maxLen); + + $splitPos = false; + + foreach (['. ', '! ', '? '] as $delimiter) { + $pos = mb_strrpos($segment, $delimiter); + + if ($pos !== false && ($splitPos === false || $pos > $splitPos)) { + $splitPos = $pos + mb_strlen($delimiter) - 1; + } + } + + if ($splitPos === false || $splitPos < 1) { + $splitPos = mb_strrpos($segment, ' '); + } + + if ($splitPos === false || $splitPos < 1) { + $splitPos = $maxLen; + } + + $chunks[] = trim(mb_substr($message, 0, $splitPos)); + $message = trim(mb_substr($message, $splitPos)); + } + + if ($message !== '') { + $chunks[] = $message; + } + + return $chunks; + } + + private function postTweet( + string $text, + ?string $replyToId, + string $consumerKey, + string $consumerSecret, + string $accessToken, + string $tokenSecret + ): array { + $apiUrl = 'https://api.twitter.com/2/tweets'; + + $body = ['text' => $text]; + + if ($replyToId !== null) { + $body['reply'] = ['in_reply_to_tweet_id' => $replyToId]; + } + + $postData = json_encode($body); + $authHeader = $this->buildOAuth1Header('POST', $apiUrl, $consumerKey, $consumerSecret, $accessToken, $tokenSecret); + + $ch = curl_init($apiUrl); + curl_setopt_array($ch, [ + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => $postData, + CURLOPT_HTTPHEADER => [ + 'Content-Type: application/json', + 'Authorization: ' . $authHeader, + ], + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 30, + ]); + + $response = curl_exec($ch); + + if ($response === false) { + $curlError = curl_error($ch); + curl_close($ch); + + return ['success' => false, 'platform_post_id' => '', 'response' => ['error' => 'Connection error: ' . $curlError]]; + } + + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + $data = json_decode($response, true) ?: []; + + if ($httpCode === 201 && !empty($data['data']['id'])) { + return ['success' => true, 'platform_post_id' => $data['data']['id'], 'response' => $data]; + } + + return ['success' => false, 'platform_post_id' => '', 'response' => $data]; + } + + private function postThread( + array $chunks, + string $consumerKey, + string $consumerSecret, + string $accessToken, + string $tokenSecret + ): array { + $firstResult = $this->postTweet($chunks[0], null, $consumerKey, $consumerSecret, $accessToken, $tokenSecret); + + if (!$firstResult['success']) { + return $firstResult; + } + + $rootId = $firstResult['platform_post_id']; + $previousId = $rootId; + + for ($i = 1, $count = \count($chunks); $i < $count; $i++) { + $result = $this->postTweet($chunks[$i], $previousId, $consumerKey, $consumerSecret, $accessToken, $tokenSecret); + + if (!$result['success']) { + return [ + 'success' => false, + 'platform_post_id' => $rootId, + 'response' => [ + 'error' => 'Thread failed at tweet ' . ($i + 1) . ' of ' . $count, + 'root_tweet' => $rootId, + 'failed_tweet' => $result['response'], + ], + ]; + } + + $previousId = $result['platform_post_id']; + } + + return ['success' => true, 'platform_post_id' => $rootId, 'response' => $firstResult['response']]; + } + + private function publishCostOptimized( + string $message, + array $credentials, + string $consumerKey, + string $consumerSecret, + string $accessToken, + string $tokenSecret + ): array { + $urlPattern = '/https?:\/\/\S+/'; + $urls = []; + preg_match_all($urlPattern, $message, $urls); + $urls = $urls[0] ?? []; + + $textOnly = trim(preg_replace($urlPattern, '', $message)); + $textOnly = preg_replace('/\s{2,}/', ' ', $textOnly); + + if ($textOnly === '' && $urls === []) { + return ['success' => false, 'platform_post_id' => '', 'response' => ['error' => 'Empty message after URL extraction.']]; + } + + $textChunks = $textOnly !== '' ? $this->splitIntoThread($textOnly) : []; + + if ($textChunks === [] && $urls !== []) { + $textChunks = [implode(' ', $urls)]; + $urls = []; + } + + $firstResult = $this->postTweet($textChunks[0], null, $consumerKey, $consumerSecret, $accessToken, $tokenSecret); + + if (!$firstResult['success']) { + return $firstResult; + } + + $rootId = $firstResult['platform_post_id']; + $previousId = $rootId; + + for ($i = 1, $count = \count($textChunks); $i < $count; $i++) { + $result = $this->postTweet($textChunks[$i], $previousId, $consumerKey, $consumerSecret, $accessToken, $tokenSecret); + + if (!$result['success']) { + return [ + 'success' => false, + 'platform_post_id' => $rootId, + 'response' => ['error' => 'Cost-optimized thread failed at tweet ' . ($i + 1), 'root_tweet' => $rootId], + ]; + } + + $previousId = $result['platform_post_id']; + } + + if ($urls !== []) { + $urlText = implode(' ', $urls); + $result = $this->postTweet($urlText, $previousId, $consumerKey, $consumerSecret, $accessToken, $tokenSecret); + + if (!$result['success']) { + return [ + 'success' => false, + 'platform_post_id' => $rootId, + 'response' => ['error' => 'Cost-optimized URL reply failed.', 'root_tweet' => $rootId], + ]; + } + } + + return ['success' => true, 'platform_post_id' => $rootId, 'response' => $firstResult['response']]; + } + /** * Build an OAuth 1.0a Authorization header with HMAC-SHA1 signature. */ -- 2.52.0