Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
11 changes: 6 additions & 5 deletions app/Enums/SocialAccount/Platform.php
Original file line number Diff line number Diff line change
Expand Up @@ -167,10 +167,11 @@ public function supportsAltText(): bool
* - LinkedIn UGC: 3000 (`commentary` field)
* - X standard tweet: 280 (X Premium accepts 25K — ignored, conservative)
* - TikTok caption: 2200
* - YouTube Shorts: title=100, description=5000. We feed `content` to both
* (publisher derives title from the first line via `buildTitle`), and
* Shorts UX only shows ~100 chars before "more" — capping at 100 keeps
* posts appropriate for the format.
* - YouTube Shorts: description=5000 (the cap here). The title never
* overflows: it comes from `meta.title` (validated at 100) or is
* derived from the first content line and truncated by `buildTitle`;
* the full content goes to the description unless `meta.description`
* overrides it.
* - Facebook text status: 10000 (API allows 63206; we cap below
* that — 63k-char posts are unrealistic and emoji-heavy content
* risks overflowing the TEXT column's 65535-byte ceiling)
Expand All @@ -188,7 +189,7 @@ public function maxContentLength(): int
self::LinkedIn, self::LinkedInPage => 3000,
self::X => 280,
self::TikTok => 2200,
self::YouTube => 100,
self::YouTube => 5000,
self::Facebook => 10000,
self::Instagram, self::InstagramFacebook => 2200,
self::Threads => 500,
Expand Down
2 changes: 2 additions & 0 deletions app/Http/Controllers/Auth/InstagramController.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ class InstagramController extends SocialController
'instagram_business_basic',
'instagram_business_content_publish',
'instagram_business_manage_insights',
// first comment after publish (FirstCommentPoster)
'instagram_business_manage_comments',
];

public function connect(Request $request): Response
Expand Down
2 changes: 2 additions & 0 deletions app/Http/Controllers/Auth/InstagramFacebookController.php
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ class InstagramFacebookController extends MetaController
'instagram_basic',
'instagram_content_publish',
'instagram_manage_insights',
// first comment after publish (FirstCommentPoster)
'instagram_manage_comments',
];

public function connect(Request $request): Response
Expand Down
8 changes: 8 additions & 0 deletions app/Jobs/PublishToSocialPlatform.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
use App\Services\Social\Telegram\TelegramPublisher;
use App\Services\Social\ThreadsPublisher;
use App\Services\Social\TikTokPublisher;
use App\Services\Social\FirstCommentPoster;
use App\Services\Social\XPublisher;
use App\Services\Social\YouTubePublisher;
use App\Support\Social\TikTokPhotoDerivativeCleaner;
Expand Down Expand Up @@ -125,6 +126,13 @@ public function handle(): void
$publisher = $this->getPublisher();
$result = $publisher->publish($this->postPlatform);
$this->postPlatform->markAsPublished(data_get($result, 'id'), data_get($result, 'url'));

// The post is live; a failed first comment must never fail it —
// FirstCommentPoster logs and swallows its own errors.
if (($externalId = (string) data_get($result, 'id')) !== '') {
app(FirstCommentPoster::class)->post($this->postPlatform, $externalId);
}

break;
} catch (PlatformUnavailableException $e) {
$this->rescheduleForRetry($e);
Expand Down
2 changes: 1 addition & 1 deletion app/Mcp/Tools/Post/CreatePostTool.php
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ public function schema(JsonSchema $schema): array
->items($schema->object(fn ($p) => [
'social_account_id' => $p->string()->required()->description('UUID of the connected social account.'),
'content_type' => $p->string()->required()->description('Format for this platform (e.g. linkedin_post, x_post, instagram_feed).'),
'meta' => $p->object()->description('Per-platform metadata. Instagram/Facebook: aspect_ratio (1:1|4:5|16:9|original). TikTok: privacy_level (required to publish) + flags (allow_comments, allow_duet, allow_stitch, disclose, brand_content_toggle, brand_organic_toggle, is_aigc, auto_add_music). Pinterest: board_id (required to publish — call ListPinterestBoardsTool first), title (≤100), link (destination URL). Pin description comes from the post content. Discord: channel_id (required to publish — call ListDiscordChannelsTool first), mentions ([{token,label}]), embeds ([{title,description,url,image,color}]).'),
'meta' => $p->object()->description('Per-platform metadata. Instagram/Facebook: aspect_ratio (1:1|4:5|16:9|original). TikTok: privacy_level (required to publish) + flags (allow_comments, allow_duet, allow_stitch, disclose, brand_content_toggle, brand_organic_toggle, is_aigc, auto_add_music). Pinterest: board_id (required to publish — call ListPinterestBoardsTool first), title (≤100), link (destination URL). Pin description comes from the post content. Discord: channel_id (required to publish — call ListDiscordChannelsTool first), mentions ([{token,label}]), embeds ([{title,description,url,image,color}]). YouTube: description (≤5000, clickable links; falls back to post content). YouTube/Instagram: first_comment (≤2200, posted by the account right after publish).'),
]))
->description('Platforms to publish on. Accounts not listed remain available but disabled.'),
];
Expand Down
2 changes: 1 addition & 1 deletion app/Mcp/Tools/Post/UpdatePostTool.php
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ public function schema(JsonSchema $schema): array
->items($schema->object(fn ($p) => [
'id' => $p->string()->required()->description('UUID of the post_platform row (from get-post-tool / list-posts-tool).'),
'content_type' => $p->string()->description('New content_type for this platform.'),
'meta' => $p->object()->description('Per-platform metadata override. Instagram/Facebook: aspect_ratio. TikTok: privacy_level (required to publish) + flags. Pinterest: board_id (required to publish — call ListPinterestBoardsTool first), title (≤100), link (destination URL). Pin description comes from the post content. Discord: channel_id (required to publish — call ListDiscordChannelsTool first), mentions, embeds. Merged with existing meta.'),
'meta' => $p->object()->description('Per-platform metadata override. Instagram/Facebook: aspect_ratio. TikTok: privacy_level (required to publish) + flags. Pinterest: board_id (required to publish — call ListPinterestBoardsTool first), title (≤100), link (destination URL). Pin description comes from the post content. Discord: channel_id (required to publish — call ListDiscordChannelsTool first), mentions, embeds. YouTube: description (≤5000, clickable links; falls back to post content). YouTube/Instagram: first_comment (≤2200, posted by the account right after publish). Merged with existing meta.'),
]))
->description('Platforms to enable for publishing. Any platform NOT listed will be disabled. Pass an empty array to disable all.'),
];
Expand Down
102 changes: 102 additions & 0 deletions app/Services/Social/FirstCommentPoster.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
<?php

declare(strict_types=1);

namespace App\Services\Social;

use App\Enums\SocialAccount\Platform;
use App\Models\PostPlatform;
use App\Services\Social\Concerns\HasSocialHttpClient;
use Illuminate\Support\Facades\Log;

/**
* Posts the per-platform `meta.first_comment` under a just-published post.
* The classic "link in the first comment" move: YouTube descriptions bury
* links below the fold and Instagram captions don't render them at all.
*
* Runs after the post is already live, so it must never fail the publish —
* every failure is logged and swallowed.
*/
class FirstCommentPoster
{
use HasSocialHttpClient;

public function post(PostPlatform $postPlatform, string $externalId): void
{
$comment = trim((string) data_get($postPlatform->meta, 'first_comment'));

if ($comment === '') {
return;
}

try {
match ($postPlatform->platform) {
Platform::YouTube => $this->postYouTubeComment($postPlatform, $externalId, $comment),
Platform::Instagram, Platform::InstagramFacebook => $this->postInstagramComment($postPlatform, $externalId, $comment),
default => Log::warning('First comment is not supported for this platform', [
'platform' => $postPlatform->platform->value,
'post_platform_id' => $postPlatform->id,
]),
};
} catch (\Throwable $e) {
Log::warning('First comment failed (post itself is published)', [
'platform' => $postPlatform->platform->value,
'post_platform_id' => $postPlatform->id,
'error' => $e->getMessage(),
]);
}
}

/**
* commentThreads.insert needs the youtube.force-ssl scope — requested at
* connect time since the platform was added, so every account has it.
*/
private function postYouTubeComment(PostPlatform $postPlatform, string $videoId, string $comment): void
{
$account = $postPlatform->socialAccount;
$api = rtrim((string) config('trypost.platforms.youtube.data_api'), '/');

$response = $this->socialHttp()
->withToken($account->access_token)
->post("{$api}/commentThreads?part=snippet", [
'snippet' => [
'videoId' => $videoId,
'topLevelComment' => [
'snippet' => ['textOriginal' => $comment],
],
],
]);

if ($response->failed()) {
Log::warning('YouTube first comment failed', [
'post_platform_id' => $postPlatform->id,
'status' => $response->status(),
'body' => $this->redactResponseBody($response->body()),
]);
}
}

/**
* Needs instagram_business_manage_comments (direct) / instagram_manage_comments
* (via Facebook Page) — accounts connected before the scope was requested
* get a 4xx here, which is logged and ignored; reconnecting fixes it.
*/
private function postInstagramComment(PostPlatform $postPlatform, string $mediaId, string $comment): void
{
$account = $postPlatform->socialAccount;
$baseUrl = $account->platform->instagramGraphBaseUrl();

$response = $this->socialHttp()->post("{$baseUrl}/{$mediaId}/comments", [
'message' => $comment,
'access_token' => $account->access_token,
]);

if ($response->failed()) {
Log::warning('Instagram first comment failed', [
'post_platform_id' => $postPlatform->id,
'status' => $response->status(),
'body' => $this->redactResponseBody($response->body()),
]);
}
}
}
14 changes: 13 additions & 1 deletion app/Services/Social/YouTubePublisher.php
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ private function publishShort(PostPlatform $postPlatform, $media, SocialAccount
}

$title = $this->buildTitle($content);
$description = $content;
$description = $this->resolveDescription($postPlatform, $content);

$tempFile = tempnam(sys_get_temp_dir(), 'yt_upload_');
$handle = null;
Expand Down Expand Up @@ -221,6 +221,18 @@ private function buildTitle(string $content): string
return $title.$shortsTag;
}

/**
* The video description: the per-platform `meta.description` when the user
* provided one (YouTube allows 5000 chars and renders links clickable),
* otherwise the post content — the pre-meta behavior.
*/
private function resolveDescription(PostPlatform $postPlatform, string $content): string
{
$description = trim((string) data_get($postPlatform->meta, 'description'));

return $description !== '' ? $description : $content;
}

private function handleGoogleError(Exception $e): never
{
throw YouTubePublishException::fromGoogleException($e);
Expand Down
14 changes: 14 additions & 0 deletions app/Support/PostPlatformMetaRules.php
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,16 @@ public static function rules(): array
'platforms.*.meta.brand_content_toggle' => ['sometimes', 'boolean'],
'platforms.*.meta.brand_organic_toggle' => ['sometimes', 'boolean'],

// YouTube — full video description (YouTube allows 5000 chars with
// clickable links; without it the publisher falls back to the post
// content, which is capped at the 100-char Shorts title).
'platforms.*.meta.description' => ['sometimes', 'nullable', 'string', 'max:5000'],

// First comment posted right after a successful publish (YouTube and
// Instagram). Capped at Instagram's comment limit, the stricter of
// the two.
'platforms.*.meta.first_comment' => ['sometimes', 'nullable', 'string', 'max:2200'],

// Pinterest
'platforms.*.meta.board_id' => ['sometimes', 'nullable', 'string'],
'platforms.*.meta.title' => ['sometimes', 'nullable', 'string', 'max:100'],
Expand Down Expand Up @@ -90,6 +100,8 @@ public static function messages(): array
'platforms.*.meta.link.url' => __('posts.form.pinterest.link_invalid'),
'platforms.*.meta.link.max' => __('posts.form.pinterest.link_max'),
'platforms.*.meta.title.max' => __('posts.form.pinterest.title_max'),
'platforms.*.meta.description.max' => __('posts.form.youtube.description_max'),
'platforms.*.meta.first_comment.max' => __('posts.form.first_comment.max'),
];
}

Expand All @@ -103,6 +115,8 @@ public static function attributes(): array
return [
'platforms.*.meta.title' => __('posts.form.pinterest.title'),
'platforms.*.meta.link' => __('posts.form.pinterest.link'),
'platforms.*.meta.description' => __('posts.form.youtube.description'),
'platforms.*.meta.first_comment' => __('posts.form.first_comment.label'),
];
}

Expand Down
15 changes: 15 additions & 0 deletions lang/ar/posts.php
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,21 @@
'document_title' => 'عنوان المستند',
'document_title_placeholder' => 'يظهر على منشور مستند PDF الخاص بك',
],
'youtube' => [
'settings' => 'إعدادات YouTube',
'posting_to' => 'النشر في',
'description' => 'الوصف',
'description_placeholder' => 'الوصف الكامل للفيديو: معلومات وروابط ووسوم…',
'description_hint' => 'حتى 5000 حرف، والروابط قابلة للنقر. إذا تُرك فارغًا يُستخدم نص المنشور.',
'description_max' => 'لا يمكن أن يتجاوز الوصف 5000 حرف.',
],
'first_comment' => [
'label' => 'التعليق الأول',
'placeholder' => 'رابط وإضافات للتعليق الأول…',
'hint' => 'يُنشر باسم قناتك فور نشر الفيديو.',
'hint_instagram' => 'يُنشر فور النشر — المكان الكلاسيكي للروابط التي لا تكون قابلة للنقر في وصف Instagram. أعد ربط الحساب مرة واحدة لمنح إذن التعليقات.',
'max' => 'لا يمكن أن يتجاوز التعليق الأول 2200 حرف.',
],
'pinterest' => [
'settings' => 'إعدادات Pinterest',
'posting_to' => 'النشر إلى',
Expand Down
15 changes: 15 additions & 0 deletions lang/de/posts.php
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,21 @@
'document_title' => 'Dokumenttitel',
'document_title_placeholder' => 'Wird bei deinem PDF-Dokument-Beitrag angezeigt',
],
'youtube' => [
'settings' => 'YouTube-Einstellungen',
'posting_to' => 'Veröffentlichen auf',
'description' => 'Beschreibung',
'description_placeholder' => 'Vollständige Videobeschreibung: Fakten, Links, Hashtags…',
'description_hint' => 'Bis zu 5000 Zeichen, Links sind klickbar. Wenn leer, wird der Beitragstext verwendet.',
'description_max' => 'Die Beschreibung darf 5000 Zeichen nicht überschreiten.',
],
'first_comment' => [
'label' => 'Erster Kommentar',
'placeholder' => 'Link und Extras für den ersten Kommentar…',
'hint' => 'Wird direkt nach der Veröffentlichung vom Kanal gepostet.',
'hint_instagram' => 'Wird direkt nach dem Beitrag gepostet — der klassische Ort für Links, die in Instagram-Beschreibungen nicht klickbar sind. Konto einmal neu verbinden, um die Kommentar-Berechtigung zu erteilen.',
'max' => 'Der erste Kommentar darf 2200 Zeichen nicht überschreiten.',
],
'pinterest' => [
'settings' => 'Pinterest-Einstellungen',
'posting_to' => 'Veröffentlichen auf',
Expand Down
15 changes: 15 additions & 0 deletions lang/el/posts.php
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,21 @@
'document_title' => 'Τίτλος εγγράφου',
'document_title_placeholder' => 'Εμφανίζεται στη δημοσίευση εγγράφου PDF σας',
],
'youtube' => [
'settings' => 'Ρυθμίσεις YouTube',
'posting_to' => 'Δημοσίευση σε',
'description' => 'Περιγραφή',
'description_placeholder' => 'Πλήρης περιγραφή βίντεο: πληροφορίες, σύνδεσμοι, hashtags…',
'description_hint' => 'Έως 5000 χαρακτήρες, οι σύνδεσμοι είναι κλικαρίσιμοι. Αν είναι κενό, χρησιμοποιείται το κείμενο της ανάρτησης.',
'description_max' => 'Η περιγραφή δεν μπορεί να υπερβαίνει τους 5000 χαρακτήρες.',
],
'first_comment' => [
'label' => 'Πρώτο σχόλιο',
'placeholder' => 'Σύνδεσμος και πρόσθετα για το πρώτο σχόλιο…',
'hint' => 'Δημοσιεύεται από το κανάλι σας αμέσως μετά τη δημοσίευση του βίντεο.',
'hint_instagram' => 'Δημοσιεύεται αμέσως μετά την ανάρτηση — το κλασικό σημείο για συνδέσμους που οι λεζάντες του Instagram δεν κάνουν κλικαρίσιμους. Επανασυνδέστε τον λογαριασμό μία φορά για να δώσετε το δικαίωμα σχολίων.',
'max' => 'Το πρώτο σχόλιο δεν μπορεί να υπερβαίνει τους 2200 χαρακτήρες.',
],
'pinterest' => [
'settings' => 'Ρυθμίσεις Pinterest',
'posting_to' => 'Δημοσίευση σε',
Expand Down
15 changes: 15 additions & 0 deletions lang/en/posts.php
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,21 @@
'document_title' => 'Document title',
'document_title_placeholder' => 'Shown on your PDF document post',
],
'youtube' => [
'settings' => 'YouTube settings',
'posting_to' => 'Posting to',
'description' => 'Description',
'description_placeholder' => 'Full video description: facts, links, hashtags…',
'description_hint' => 'Up to 5000 characters, links are clickable. When empty, the post text is used.',
'description_max' => 'Description may not exceed 5000 characters.',
],
'first_comment' => [
'label' => 'First comment',
'placeholder' => 'Link and extras for the first comment…',
'hint' => 'Posted by your channel right after the video is published.',
'hint_instagram' => 'Posted right after publishing — the classic spot for links Instagram captions can\'t make clickable. Requires reconnecting the account once to grant the comments permission.',
'max' => 'First comment may not exceed 2200 characters.',
],
'pinterest' => [
'settings' => 'Pinterest Settings',
'posting_to' => 'Posting to',
Expand Down
Loading