feat: add Twitter thread support and cost warning #194
@@ -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
@@ -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.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user