feat: add Threads carousel, polls, and spoiler support (#153)
Universal: Auto Version Bump / Version Bump (push) Successful in 10s

Authored-by: Moko Consulting
This commit is contained in:
2026-06-27 20:25:33 -05:00
parent a111f5b5e9
commit 7bd151ad62
2 changed files with 130 additions and 50 deletions
+4
View File
@@ -6,6 +6,10 @@
- **Nostr**: Publishes kind-1 text note events to multiple relays with automatic failover
- **Nostr**: Raw WebSocket client using stream_socket_client (no external dependencies)
- **Nostr**: Public key derivation and event signing via secp256k1 elliptic curve math
- **Threads carousel**: Support up to 20-item carousel posts via Threads API multi-container flow
- **Threads polls**: Poll creation support via poll_options parameter (2-4 options)
- **Threads spoiler tags**: Content warning / spoiler flag support for Threads posts
- **Threads text-only optimization**: Simplified single-step flow for text-only posts without media
### Fixed
- Webservices plugin Joomla 6 compatibility — `onBeforeApiRoute` receives `BeforeApiRouteEvent` object, extract router via `$event->getRouter()`
@@ -17,15 +17,13 @@ use Joomla\CMS\Plugin\CMSPlugin;
use Joomla\Component\MokoSuiteCross\Administrator\Service\MokoSuiteCrossServiceInterface;
use Joomla\Event\SubscriberInterface;
/**
* Threads (Meta) service plugin for MokoSuiteCross.
*
* Uses the Threads Publishing API — a 2-step flow:
* 1. Create a media container via POST /{user_id}/threads
* 2. Publish the container via POST /{user_id}/threads_publish
*/
class ThreadsService extends CMSPlugin implements SubscriberInterface, MokoSuiteCrossServiceInterface
{
private const API_BASE = 'https://graph.threads.net/v1.0/';
private const MAX_CAROUSEL_ITEMS = 20;
private const MAX_POLL_OPTIONS = 4;
private const MIN_POLL_OPTIONS = 2;
public static function getSubscribedEvents(): array
{
return ['onMokoSuiteCrossGetServices' => 'onMokoSuiteCrossGetServices'];
@@ -50,62 +48,104 @@ class ThreadsService extends CMSPlugin implements SubscriberInterface, MokoSuite
return ['success' => false, 'platform_post_id' => '', 'response' => ['error' => 'Missing access token or user ID.']];
}
// Step 1: Create media container
$containerUrl = 'https://graph.threads.net/v1.0/' . urlencode($userId) . '/threads';
$text = mb_substr($message, 0, 500);
$media = array_filter($media);
if (\count($media) > 1) {
return $this->publishCarousel($text, $media, $userId, $token, $params);
}
return $this->publishSinglePost($text, $media, $userId, $token, $params);
}
private function publishSinglePost(string $text, array $media, string $userId, string $token, array $params): array
{
$containerUrl = self::API_BASE . urlencode($userId) . '/threads';
$containerData = [
'text' => mb_substr($message, 0, 500),
'text' => $text,
'access_token' => $token,
];
// Attach image if provided
if (!empty($media[0])) {
$containerData['media_type'] = 'IMAGE';
$containerData['image_url'] = $media[0];
$mediaType = $this->detectMediaType($media[0]);
$containerData['media_type'] = $mediaType;
$containerData[$mediaType === 'VIDEO' ? 'video_url' : 'image_url'] = $media[0];
} else {
$containerData['media_type'] = 'TEXT';
}
$ch = curl_init($containerUrl);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => http_build_query($containerData),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
]);
$this->applyPollOptions($containerData, $params);
$this->applySpoilerFlag($containerData, $params);
$response = curl_exec($ch);
$result = $this->apiPost($containerUrl, $containerData);
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 < 200 || $httpCode >= 300 || empty($data['id'])) {
return ['success' => false, 'platform_post_id' => '', 'response' => $data];
if (!$result['success']) {
return $result;
}
$containerId = $data['id'];
return $this->publishContainer($userId, $token, $result['response']['id']);
}
// Step 2: Publish the container
$publishUrl = 'https://graph.threads.net/v1.0/' . urlencode($userId) . '/threads_publish';
private function publishCarousel(string $text, array $media, string $userId, string $token, array $params): array
{
$items = \array_slice($media, 0, self::MAX_CAROUSEL_ITEMS);
$childIds = [];
foreach ($items as $url) {
$mediaType = $this->detectMediaType($url);
$childData = [
'is_carousel_item' => 'true',
'media_type' => $mediaType,
'access_token' => $token,
];
$childData[$mediaType === 'VIDEO' ? 'video_url' : 'image_url'] = $url;
$childUrl = self::API_BASE . urlencode($userId) . '/threads';
$result = $this->apiPost($childUrl, $childData);
if (!$result['success']) {
return $result;
}
$childIds[] = $result['response']['id'];
}
$carouselData = [
'media_type' => 'CAROUSEL',
'children' => implode(',', $childIds),
'text' => $text,
'access_token' => $token,
];
$this->applySpoilerFlag($carouselData, $params);
$carouselUrl = self::API_BASE . urlencode($userId) . '/threads';
$result = $this->apiPost($carouselUrl, $carouselData);
if (!$result['success']) {
return $result;
}
return $this->publishContainer($userId, $token, $result['response']['id']);
}
private function publishContainer(string $userId, string $token, string $containerId): array
{
$publishUrl = self::API_BASE . urlencode($userId) . '/threads_publish';
$publishData = [
'creation_id' => $containerId,
'access_token' => $token,
];
$ch = curl_init($publishUrl);
return $this->apiPost($publishUrl, $publishData);
}
private function apiPost(string $url, array $data): array
{
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => http_build_query($publishData),
CURLOPT_POSTFIELDS => http_build_query($data),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
]);
@@ -113,24 +153,60 @@ class ThreadsService extends CMSPlugin implements SubscriberInterface, MokoSuite
$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) ?: [];
$responseData = json_decode($response, true) ?: [];
if ($httpCode >= 200 && $httpCode < 300 && !empty($data['id'])) {
return ['success' => true, 'platform_post_id' => (string) $data['id'], 'response' => $data];
if ($httpCode >= 200 && $httpCode < 300 && !empty($responseData['id'])) {
return ['success' => true, 'platform_post_id' => (string) $responseData['id'], 'response' => $responseData];
}
return ['success' => false, 'platform_post_id' => '', 'response' => $data];
return ['success' => false, 'platform_post_id' => '', 'response' => $responseData];
}
private function applyPollOptions(array &$data, array $params): void
{
$options = $params['poll_options'] ?? [];
if (empty($options) || !\is_array($options)) {
return;
}
$options = \array_slice($options, 0, self::MAX_POLL_OPTIONS);
if (\count($options) < self::MIN_POLL_OPTIONS) {
return;
}
$data['poll'] = json_encode(['options' => array_values($options)]);
}
private function applySpoilerFlag(array &$data, array $params): void
{
if (!empty($params['spoiler'])) {
$data['spoiler'] = 'true';
}
}
private function detectMediaType(string $url): string
{
$path = strtolower(parse_url($url, PHP_URL_PATH) ?? '');
$videoExtensions = ['.mp4', '.mov', '.avi', '.wmv', '.webm'];
foreach ($videoExtensions as $ext) {
if (str_ends_with($path, $ext)) {
return 'VIDEO';
}
}
return 'IMAGE';
}
public function validateCredentials(array $credentials): array
@@ -142,7 +218,7 @@ class ThreadsService extends CMSPlugin implements SubscriberInterface, MokoSuite
return ['valid' => false, 'message' => 'Access token and user ID are required.', 'account_name' => ''];
}
$ch = curl_init('https://graph.threads.net/v1.0/' . urlencode($userId) . '?fields=username&access_token=' . urlencode($token));
$ch = curl_init(self::API_BASE . urlencode($userId) . '?fields=username&access_token=' . urlencode($token));
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,