feat: add Threads carousel, polls, and spoiler support (#153)
Universal: Auto Version Bump / Version Bump (push) Successful in 10s
Universal: Auto Version Bump / Version Bump (push) Successful in 10s
Authored-by: Moko Consulting
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user