Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
564f157
Fix MCP upload rate limits and Instagram Reel duration caps.
paulocastellano Jul 25, 2026
3175fbe
Centralize MCP upload size caps on trypost.media.
paulocastellano Jul 25, 2026
302d902
Move signed upload URL TTL into config/trypost.php.
paulocastellano Jul 25, 2026
3d4a42c
Share content-type video duration caps to the frontend via Inertia.
paulocastellano Jul 25, 2026
b1cc1a6
Raise MCP upload IP backstop to 1200/min.
paulocastellano Jul 25, 2026
ff0bfa8
Expose Pinterest board IDs via MCP and REST API.
paulocastellano Jul 25, 2026
b28b18e
Address PR review: stream MCP uploads and close listing gaps.
paulocastellano Jul 25, 2026
7b986fc
Centralize content-type media rules on the ContentType enum.
paulocastellano Jul 25, 2026
c040ba4
Authorize API social accounts via SocialAccountPolicy.
paulocastellano Jul 25, 2026
491895c
Page through all Pinterest boards until the bookmark ends.
paulocastellano Jul 25, 2026
0d4e5fd
Restore media-rule parity and release failed MCP upload tokens.
paulocastellano Jul 25, 2026
e4779df
Clamp media byte caps to upload limits and surface truncated board li…
paulocastellano Jul 25, 2026
d8e43bc
Unwrap Pinterest board lists for the web editors.
paulocastellano Jul 25, 2026
b9194b9
Surface Pinterest board truncation in the web editors.
paulocastellano Jul 25, 2026
6bfb88d
Fix Generate node clamping image count to 0 for Pinterest.
paulocastellano Jul 25, 2026
4cbdfa3
Hide video-only formats in the automation Generate node.
paulocastellano Jul 25, 2026
d35cc52
Enforce carousel min slides in Generate and expose min_media_count on…
paulocastellano Jul 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 4 additions & 5 deletions app/Actions/Automation/Automation/GetAutomationEditorData.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,25 +4,24 @@

namespace App\Actions\Automation\Automation;

use App\Actions\SocialAccount\ListPinterestBoards;
use App\Enums\SocialAccount\Platform;
use App\Models\Automation;
use App\Models\SocialAccount;
use App\Services\Social\PinterestPublisher;
use App\Services\Social\TikTokCreatorInfo;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Collection as SupportCollection;

class GetAutomationEditorData
{
public function __construct(
private PinterestPublisher $pinterestPublisher,
private TikTokCreatorInfo $tikTokCreatorInfo,
) {}

/**
* @return array{
* socialAccounts: Collection<int, SocialAccount>,
* pinterestBoards: SupportCollection<string, array<int, mixed>>,
* pinterestBoards: SupportCollection<string, array{boards: list<array{id: string, name: string}>, truncated: bool}>,
* tiktokCreatorInfos: SupportCollection<string, mixed>,
* }
*/
Expand All @@ -34,8 +33,8 @@ public function __invoke(Automation $automation): array
->where('platform', Platform::Pinterest)
->mapWithKeys(fn ($account) => [
$account->id => rescue(
fn () => $this->pinterestPublisher->getBoards($account),
[],
fn () => ListPinterestBoards::execute($account),
['boards' => [], 'truncated' => false],
report: false,
),
]);
Expand Down
19 changes: 19 additions & 0 deletions app/Actions/SocialAccount/ListDiscordChannels.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

declare(strict_types=1);

namespace App\Actions\SocialAccount;

use App\Models\SocialAccount;
use App\Services\Social\Discord\DiscordClient;

class ListDiscordChannels
{
/**
* @return list<array{id: string, name: string}>
*/
public static function execute(SocialAccount $account): array
{
return app(DiscordClient::class)->channels((string) $account->platform_user_id);
}
}
34 changes: 34 additions & 0 deletions app/Actions/SocialAccount/ListPinterestBoards.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php

declare(strict_types=1);

namespace App\Actions\SocialAccount;

use App\Models\SocialAccount;
use App\Services\Social\PinterestPublisher;
use Illuminate\Support\Collection;

class ListPinterestBoards
{
/**
* @return array{boards: list<array{id: string, name: string}>, truncated: bool}
*/
public static function execute(SocialAccount $account): array
{
$result = app(PinterestPublisher::class)->getBoards($account);

$boards = Collection::make(data_get($result, 'boards', []))
->map(fn (mixed $board): array => [
'id' => (string) data_get($board, 'id'),
'name' => (string) data_get($board, 'name'),
])
->filter(fn (array $board): bool => $board['id'] !== '')
->values()
->all();

return [
'boards' => $boards,
'truncated' => (bool) data_get($result, 'truncated', false),
];
}
}
264 changes: 263 additions & 1 deletion app/Enums/PostPlatform/ContentType.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace App\Enums\PostPlatform;

use App\Enums\Media\Type as MediaType;
use App\Enums\SocialAccount\Platform as SocialPlatform;

enum ContentType: string
Expand Down Expand Up @@ -179,6 +180,267 @@ public function maxMediaCount(): int
};
}

/**
* Maximum video duration in seconds for this content type, when the
* platform publishes a hard cap via API. Null when unlimited, unknown,
* or enforced dynamically (e.g. TikTok creator_info).
*
* Single source of truth for web (via Inertia shared props), REST API,
* and MCP content-type listings.
*/
public function maxVideoDurationSec(): ?int
{
return match ($this) {
self::InstagramFeed => 60,
self::InstagramReel => 15 * 60,
self::InstagramStory => 60,
self::FacebookPost => 240 * 60,
self::FacebookReel => 90,
self::FacebookStory => 60,
self::LinkedInPost, self::LinkedInPagePost => 10 * 60,
self::YouTubeShort => 3 * 60,
self::PinterestVideoPin => 15 * 60,
self::XPost => 140,
self::ThreadsPost => 5 * 60,
self::BlueskyPost => 60,
default => null,
};
}

/**
* Minimum attachments required (0 = no floor beyond requiresMedia()).
*/
public function minMediaCount(): int
{
return match ($this) {
self::PinterestCarousel => 2,
self::TikTokPhoto => 1,
default => 0,
};
}

/**
* Whether animated GIFs are accepted (kept as GIF, not flattened to JPEG).
*/
public function acceptsGif(): bool
{
return match ($this) {
self::XPost, self::BlueskyPost, self::MastodonPost,
self::DiscordMessage, self::TelegramPost => true,
default => false,
};
}

/**
* Per-type image size cap in bytes, capped at the global upload hard limit
* (trypost.media.max_size_mb.image). Null when images are not accepted or
* the platform has no tighter editor-side limit than that hard cap.
*/
public function maxImageBytes(): ?int
{
$bytes = match ($this) {
self::InstagramFeed, self::InstagramStory => self::bytesFromMb(8),
self::FacebookPost => self::bytesFromMb(4),
self::LinkedInPost, self::LinkedInPagePost => self::bytesFromMb(5),
self::PinterestPin, self::PinterestCarousel => self::bytesFromMb(20),
self::XPost => self::bytesFromMb(5),
self::ThreadsPost => self::bytesFromMb(8),
self::MastodonPost => self::bytesFromMb(10),
default => null,
};

return self::capToHardLimit($bytes, MediaType::Image);
}

/**
* Per-type video size cap in bytes, capped at the global upload hard limit
* (trypost.media.max_size_mb.video). Null when videos are not accepted or
* the platform has no tighter editor-side limit than that hard cap.
*/
public function maxVideoBytes(): ?int
{
$bytes = match ($this) {
self::InstagramFeed, self::InstagramStory => self::bytesFromMb(100),
self::InstagramReel => self::bytesFromGb(1),
self::FacebookPost => self::bytesFromGb(10),
self::FacebookReel, self::FacebookStory => self::bytesFromGb(1),
self::LinkedInPost, self::LinkedInPagePost => self::bytesFromGb(5),
self::YouTubeShort => self::bytesFromGb(256),
self::PinterestVideoPin => self::bytesFromGb(2),
self::XPost => self::bytesFromMb(512),
self::ThreadsPost => self::bytesFromGb(1),
self::BlueskyPost => self::bytesFromMb(100),
self::MastodonPost => self::bytesFromMb(40),
default => null,
};

return self::capToHardLimit($bytes, MediaType::Video);
}

/**
* Per-type PDF size cap in bytes, capped at the global upload hard limit.
* Null when documents are not accepted.
*/
public function maxDocumentBytes(): ?int
{
$bytes = match ($this) {
self::LinkedInPost, self::LinkedInPagePost => self::bytesFromMb(100),
default => null,
};

return self::capToHardLimit($bytes, MediaType::Document);
}

/**
* Soft aspect-ratio window used by the Vue cropper / media picker.
*
* @return array{min: float, max: float}|null
*/
public function aspectRatioBounds(): ?array
{
return match ($this) {
self::InstagramFeed => ['min' => 0.8, 'max' => 1.91],
self::InstagramReel, self::InstagramStory,
self::FacebookReel, self::FacebookStory,
self::YouTubeShort => ['min' => 0.5, 'max' => 0.6],
default => null,
};
}

/**
* Whether the editor should auto-fit still images into the story frame.
*/
public function autoFitsImage(): bool
{
return $this === self::InstagramStory;
}

/**
* Full media-rule payload for the Vue editor (and any other consumer).
* Shared once via Inertia — do not re-hardcode these numbers in JS.
*
* @return array{
* max_files: int,
* min_files: int|null,
* accept_images: bool,
* accept_videos: bool,
* accept_documents: bool,
* requires_media: bool,
* accepts_gif: bool,
* forbids_mixed_media: bool,
* max_image_bytes: int|null,
* max_video_bytes: int|null,
* max_document_bytes: int|null,
* max_video_duration_sec: int|null,
* aspect_ratio_min: float|null,
* aspect_ratio_max: float|null,
* auto_fits_image: bool
* }
*/
public function mediaRules(): array
{
$bounds = $this->aspectRatioBounds();
$minFiles = $this->minMediaCount();

return [
'max_files' => $this->maxMediaCount(),
'min_files' => $minFiles > 0 ? $minFiles : null,
'accept_images' => $this->supportsImage(),
'accept_videos' => $this->supportsVideo(),
'accept_documents' => $this->supportsDocument(),
'requires_media' => $this->requiresMedia(),
'accepts_gif' => $this->acceptsGif(),
'forbids_mixed_media' => ! $this->supportsMixedMedia(),
'max_image_bytes' => $this->maxImageBytes(),
'max_video_bytes' => $this->maxVideoBytes(),
'max_document_bytes' => $this->maxDocumentBytes(),
'max_video_duration_sec' => $this->maxVideoDurationSec(),
'aspect_ratio_min' => $bounds['min'] ?? null,
'aspect_ratio_max' => $bounds['max'] ?? null,
'auto_fits_image' => $this->autoFitsImage(),
];
}

/**
* Content-type row for REST / MCP listings (agents + API clients).
* Keep in sync with mediaRules() capability fields — do not omit mins.
*
* @return array{
* value: string,
* label: string,
* description: string,
* max_media_count: int,
* min_media_count: int,
* requires_media: bool,
* accept_images: bool,
* accept_videos: bool,
* accept_documents: bool,
* accepts_gif: bool,
* forbids_mixed_media: bool,
* max_video_duration_sec: int|null,
* max_image_bytes: int|null,
* max_video_bytes: int|null,
* max_document_bytes: int|null
* }
*/
public function toListingArray(): array
{
return [
'value' => $this->value,
'label' => $this->label(),
'description' => $this->description(),
'max_media_count' => $this->maxMediaCount(),
'min_media_count' => $this->minMediaCount(),
'requires_media' => $this->requiresMedia(),
'accept_images' => $this->supportsImage(),
'accept_videos' => $this->supportsVideo(),
'accept_documents' => $this->supportsDocument(),
'accepts_gif' => $this->acceptsGif(),
'forbids_mixed_media' => ! $this->supportsMixedMedia(),
'max_video_duration_sec' => $this->maxVideoDurationSec(),
'max_image_bytes' => $this->maxImageBytes(),
'max_video_bytes' => $this->maxVideoBytes(),
'max_document_bytes' => $this->maxDocumentBytes(),
];
}

/**
* @return array<string, array<string, mixed>>
*/
public static function mediaRulesForFrontend(): array
{
$rules = [];

foreach (self::cases() as $type) {
$rules[$type->value] = $type->mediaRules();
}

return $rules;
}

private static function bytesFromMb(int $megabytes): int
{
return $megabytes * 1024 * 1024;
}

private static function bytesFromGb(int $gigabytes): int
{
return $gigabytes * 1024 * 1024 * 1024;
}

/**
* Never advertise a soft platform ceiling above what trypost.media allows
* on upload (web / API / MCP signed URL).
*/
private static function capToHardLimit(?int $platformBytes, MediaType $type): ?int
{
if ($platformBytes === null) {
return null;
}

return min($platformBytes, $type->maxSizeInBytes());
}

public function supportsVideo(): bool
{
return match ($this) {
Expand Down Expand Up @@ -263,7 +525,6 @@ public function requiresMedia(): bool
self::MastodonPost => false,
self::TelegramPost => false,
self::FacebookPost => false,
self::InstagramFeed => false,
self::DiscordMessage => false,
default => true,
};
Expand All @@ -288,6 +549,7 @@ public static function aiSupported(): array
self::MastodonPost,
self::FacebookPost,
self::PinterestPin,
self::PinterestCarousel,
];
}

Expand Down
Loading