Skip to content

Commit b16e0bd

Browse files
Merge pull request #205 from trypostit/fix/mcp-upload-rate-limit-and-reel-durations
Fix MCP upload rate limits and Instagram Reel duration caps
2 parents 596ad49 + d35cc52 commit b16e0bd

86 files changed

Lines changed: 2134 additions & 300 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

app/Actions/Automation/Automation/GetAutomationEditorData.php

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,25 +4,24 @@
44

55
namespace App\Actions\Automation\Automation;
66

7+
use App\Actions\SocialAccount\ListPinterestBoards;
78
use App\Enums\SocialAccount\Platform;
89
use App\Models\Automation;
910
use App\Models\SocialAccount;
10-
use App\Services\Social\PinterestPublisher;
1111
use App\Services\Social\TikTokCreatorInfo;
1212
use Illuminate\Database\Eloquent\Collection;
1313
use Illuminate\Support\Collection as SupportCollection;
1414

1515
class GetAutomationEditorData
1616
{
1717
public function __construct(
18-
private PinterestPublisher $pinterestPublisher,
1918
private TikTokCreatorInfo $tikTokCreatorInfo,
2019
) {}
2120

2221
/**
2322
* @return array{
2423
* socialAccounts: Collection<int, SocialAccount>,
25-
* pinterestBoards: SupportCollection<string, array<int, mixed>>,
24+
* pinterestBoards: SupportCollection<string, array{boards: list<array{id: string, name: string}>, truncated: bool}>,
2625
* tiktokCreatorInfos: SupportCollection<string, mixed>,
2726
* }
2827
*/
@@ -34,8 +33,8 @@ public function __invoke(Automation $automation): array
3433
->where('platform', Platform::Pinterest)
3534
->mapWithKeys(fn ($account) => [
3635
$account->id => rescue(
37-
fn () => $this->pinterestPublisher->getBoards($account),
38-
[],
36+
fn () => ListPinterestBoards::execute($account),
37+
['boards' => [], 'truncated' => false],
3938
report: false,
4039
),
4140
]);
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\Actions\SocialAccount;
6+
7+
use App\Models\SocialAccount;
8+
use App\Services\Social\Discord\DiscordClient;
9+
10+
class ListDiscordChannels
11+
{
12+
/**
13+
* @return list<array{id: string, name: string}>
14+
*/
15+
public static function execute(SocialAccount $account): array
16+
{
17+
return app(DiscordClient::class)->channels((string) $account->platform_user_id);
18+
}
19+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\Actions\SocialAccount;
6+
7+
use App\Models\SocialAccount;
8+
use App\Services\Social\PinterestPublisher;
9+
use Illuminate\Support\Collection;
10+
11+
class ListPinterestBoards
12+
{
13+
/**
14+
* @return array{boards: list<array{id: string, name: string}>, truncated: bool}
15+
*/
16+
public static function execute(SocialAccount $account): array
17+
{
18+
$result = app(PinterestPublisher::class)->getBoards($account);
19+
20+
$boards = Collection::make(data_get($result, 'boards', []))
21+
->map(fn (mixed $board): array => [
22+
'id' => (string) data_get($board, 'id'),
23+
'name' => (string) data_get($board, 'name'),
24+
])
25+
->filter(fn (array $board): bool => $board['id'] !== '')
26+
->values()
27+
->all();
28+
29+
return [
30+
'boards' => $boards,
31+
'truncated' => (bool) data_get($result, 'truncated', false),
32+
];
33+
}
34+
}

app/Enums/PostPlatform/ContentType.php

Lines changed: 263 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
namespace App\Enums\PostPlatform;
66

7+
use App\Enums\Media\Type as MediaType;
78
use App\Enums\SocialAccount\Platform as SocialPlatform;
89

910
enum ContentType: string
@@ -179,6 +180,267 @@ public function maxMediaCount(): int
179180
};
180181
}
181182

183+
/**
184+
* Maximum video duration in seconds for this content type, when the
185+
* platform publishes a hard cap via API. Null when unlimited, unknown,
186+
* or enforced dynamically (e.g. TikTok creator_info).
187+
*
188+
* Single source of truth for web (via Inertia shared props), REST API,
189+
* and MCP content-type listings.
190+
*/
191+
public function maxVideoDurationSec(): ?int
192+
{
193+
return match ($this) {
194+
self::InstagramFeed => 60,
195+
self::InstagramReel => 15 * 60,
196+
self::InstagramStory => 60,
197+
self::FacebookPost => 240 * 60,
198+
self::FacebookReel => 90,
199+
self::FacebookStory => 60,
200+
self::LinkedInPost, self::LinkedInPagePost => 10 * 60,
201+
self::YouTubeShort => 3 * 60,
202+
self::PinterestVideoPin => 15 * 60,
203+
self::XPost => 140,
204+
self::ThreadsPost => 5 * 60,
205+
self::BlueskyPost => 60,
206+
default => null,
207+
};
208+
}
209+
210+
/**
211+
* Minimum attachments required (0 = no floor beyond requiresMedia()).
212+
*/
213+
public function minMediaCount(): int
214+
{
215+
return match ($this) {
216+
self::PinterestCarousel => 2,
217+
self::TikTokPhoto => 1,
218+
default => 0,
219+
};
220+
}
221+
222+
/**
223+
* Whether animated GIFs are accepted (kept as GIF, not flattened to JPEG).
224+
*/
225+
public function acceptsGif(): bool
226+
{
227+
return match ($this) {
228+
self::XPost, self::BlueskyPost, self::MastodonPost,
229+
self::DiscordMessage, self::TelegramPost => true,
230+
default => false,
231+
};
232+
}
233+
234+
/**
235+
* Per-type image size cap in bytes, capped at the global upload hard limit
236+
* (trypost.media.max_size_mb.image). Null when images are not accepted or
237+
* the platform has no tighter editor-side limit than that hard cap.
238+
*/
239+
public function maxImageBytes(): ?int
240+
{
241+
$bytes = match ($this) {
242+
self::InstagramFeed, self::InstagramStory => self::bytesFromMb(8),
243+
self::FacebookPost => self::bytesFromMb(4),
244+
self::LinkedInPost, self::LinkedInPagePost => self::bytesFromMb(5),
245+
self::PinterestPin, self::PinterestCarousel => self::bytesFromMb(20),
246+
self::XPost => self::bytesFromMb(5),
247+
self::ThreadsPost => self::bytesFromMb(8),
248+
self::MastodonPost => self::bytesFromMb(10),
249+
default => null,
250+
};
251+
252+
return self::capToHardLimit($bytes, MediaType::Image);
253+
}
254+
255+
/**
256+
* Per-type video size cap in bytes, capped at the global upload hard limit
257+
* (trypost.media.max_size_mb.video). Null when videos are not accepted or
258+
* the platform has no tighter editor-side limit than that hard cap.
259+
*/
260+
public function maxVideoBytes(): ?int
261+
{
262+
$bytes = match ($this) {
263+
self::InstagramFeed, self::InstagramStory => self::bytesFromMb(100),
264+
self::InstagramReel => self::bytesFromGb(1),
265+
self::FacebookPost => self::bytesFromGb(10),
266+
self::FacebookReel, self::FacebookStory => self::bytesFromGb(1),
267+
self::LinkedInPost, self::LinkedInPagePost => self::bytesFromGb(5),
268+
self::YouTubeShort => self::bytesFromGb(256),
269+
self::PinterestVideoPin => self::bytesFromGb(2),
270+
self::XPost => self::bytesFromMb(512),
271+
self::ThreadsPost => self::bytesFromGb(1),
272+
self::BlueskyPost => self::bytesFromMb(100),
273+
self::MastodonPost => self::bytesFromMb(40),
274+
default => null,
275+
};
276+
277+
return self::capToHardLimit($bytes, MediaType::Video);
278+
}
279+
280+
/**
281+
* Per-type PDF size cap in bytes, capped at the global upload hard limit.
282+
* Null when documents are not accepted.
283+
*/
284+
public function maxDocumentBytes(): ?int
285+
{
286+
$bytes = match ($this) {
287+
self::LinkedInPost, self::LinkedInPagePost => self::bytesFromMb(100),
288+
default => null,
289+
};
290+
291+
return self::capToHardLimit($bytes, MediaType::Document);
292+
}
293+
294+
/**
295+
* Soft aspect-ratio window used by the Vue cropper / media picker.
296+
*
297+
* @return array{min: float, max: float}|null
298+
*/
299+
public function aspectRatioBounds(): ?array
300+
{
301+
return match ($this) {
302+
self::InstagramFeed => ['min' => 0.8, 'max' => 1.91],
303+
self::InstagramReel, self::InstagramStory,
304+
self::FacebookReel, self::FacebookStory,
305+
self::YouTubeShort => ['min' => 0.5, 'max' => 0.6],
306+
default => null,
307+
};
308+
}
309+
310+
/**
311+
* Whether the editor should auto-fit still images into the story frame.
312+
*/
313+
public function autoFitsImage(): bool
314+
{
315+
return $this === self::InstagramStory;
316+
}
317+
318+
/**
319+
* Full media-rule payload for the Vue editor (and any other consumer).
320+
* Shared once via Inertia — do not re-hardcode these numbers in JS.
321+
*
322+
* @return array{
323+
* max_files: int,
324+
* min_files: int|null,
325+
* accept_images: bool,
326+
* accept_videos: bool,
327+
* accept_documents: bool,
328+
* requires_media: bool,
329+
* accepts_gif: bool,
330+
* forbids_mixed_media: bool,
331+
* max_image_bytes: int|null,
332+
* max_video_bytes: int|null,
333+
* max_document_bytes: int|null,
334+
* max_video_duration_sec: int|null,
335+
* aspect_ratio_min: float|null,
336+
* aspect_ratio_max: float|null,
337+
* auto_fits_image: bool
338+
* }
339+
*/
340+
public function mediaRules(): array
341+
{
342+
$bounds = $this->aspectRatioBounds();
343+
$minFiles = $this->minMediaCount();
344+
345+
return [
346+
'max_files' => $this->maxMediaCount(),
347+
'min_files' => $minFiles > 0 ? $minFiles : null,
348+
'accept_images' => $this->supportsImage(),
349+
'accept_videos' => $this->supportsVideo(),
350+
'accept_documents' => $this->supportsDocument(),
351+
'requires_media' => $this->requiresMedia(),
352+
'accepts_gif' => $this->acceptsGif(),
353+
'forbids_mixed_media' => ! $this->supportsMixedMedia(),
354+
'max_image_bytes' => $this->maxImageBytes(),
355+
'max_video_bytes' => $this->maxVideoBytes(),
356+
'max_document_bytes' => $this->maxDocumentBytes(),
357+
'max_video_duration_sec' => $this->maxVideoDurationSec(),
358+
'aspect_ratio_min' => $bounds['min'] ?? null,
359+
'aspect_ratio_max' => $bounds['max'] ?? null,
360+
'auto_fits_image' => $this->autoFitsImage(),
361+
];
362+
}
363+
364+
/**
365+
* Content-type row for REST / MCP listings (agents + API clients).
366+
* Keep in sync with mediaRules() capability fields — do not omit mins.
367+
*
368+
* @return array{
369+
* value: string,
370+
* label: string,
371+
* description: string,
372+
* max_media_count: int,
373+
* min_media_count: int,
374+
* requires_media: bool,
375+
* accept_images: bool,
376+
* accept_videos: bool,
377+
* accept_documents: bool,
378+
* accepts_gif: bool,
379+
* forbids_mixed_media: bool,
380+
* max_video_duration_sec: int|null,
381+
* max_image_bytes: int|null,
382+
* max_video_bytes: int|null,
383+
* max_document_bytes: int|null
384+
* }
385+
*/
386+
public function toListingArray(): array
387+
{
388+
return [
389+
'value' => $this->value,
390+
'label' => $this->label(),
391+
'description' => $this->description(),
392+
'max_media_count' => $this->maxMediaCount(),
393+
'min_media_count' => $this->minMediaCount(),
394+
'requires_media' => $this->requiresMedia(),
395+
'accept_images' => $this->supportsImage(),
396+
'accept_videos' => $this->supportsVideo(),
397+
'accept_documents' => $this->supportsDocument(),
398+
'accepts_gif' => $this->acceptsGif(),
399+
'forbids_mixed_media' => ! $this->supportsMixedMedia(),
400+
'max_video_duration_sec' => $this->maxVideoDurationSec(),
401+
'max_image_bytes' => $this->maxImageBytes(),
402+
'max_video_bytes' => $this->maxVideoBytes(),
403+
'max_document_bytes' => $this->maxDocumentBytes(),
404+
];
405+
}
406+
407+
/**
408+
* @return array<string, array<string, mixed>>
409+
*/
410+
public static function mediaRulesForFrontend(): array
411+
{
412+
$rules = [];
413+
414+
foreach (self::cases() as $type) {
415+
$rules[$type->value] = $type->mediaRules();
416+
}
417+
418+
return $rules;
419+
}
420+
421+
private static function bytesFromMb(int $megabytes): int
422+
{
423+
return $megabytes * 1024 * 1024;
424+
}
425+
426+
private static function bytesFromGb(int $gigabytes): int
427+
{
428+
return $gigabytes * 1024 * 1024 * 1024;
429+
}
430+
431+
/**
432+
* Never advertise a soft platform ceiling above what trypost.media allows
433+
* on upload (web / API / MCP signed URL).
434+
*/
435+
private static function capToHardLimit(?int $platformBytes, MediaType $type): ?int
436+
{
437+
if ($platformBytes === null) {
438+
return null;
439+
}
440+
441+
return min($platformBytes, $type->maxSizeInBytes());
442+
}
443+
182444
public function supportsVideo(): bool
183445
{
184446
return match ($this) {
@@ -263,7 +525,6 @@ public function requiresMedia(): bool
263525
self::MastodonPost => false,
264526
self::TelegramPost => false,
265527
self::FacebookPost => false,
266-
self::InstagramFeed => false,
267528
self::DiscordMessage => false,
268529
default => true,
269530
};
@@ -288,6 +549,7 @@ public static function aiSupported(): array
288549
self::MastodonPost,
289550
self::FacebookPost,
290551
self::PinterestPin,
552+
self::PinterestCarousel,
291553
];
292554
}
293555

0 commit comments

Comments
 (0)