feat: add Twitter thread support and cost warning #194

Merged
jmiller merged 1 commits from feature/163-twitter-threads into dev 2026-06-28 15:36:23 +00:00
3 changed files with 208 additions and 36 deletions
+3
View File
@@ -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
@@ -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."
@@ -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.
*/