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
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
10 changes: 10 additions & 0 deletions app/Jobs/PublishToSocialPlatform.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,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\GoogleBusinessDerivativeCleaner;
Expand Down Expand Up @@ -126,6 +127,15 @@ public function handle(): void
$result = $publisher->publish($this->postPlatform);

$this->recordPublishResult($result);

// The post is live; a failed first comment must never fail it —
// FirstCommentPoster logs and swallows its own errors. Posts
// held in pending review (Google Business) get no comment.
if ($this->postPlatform->status === PostPlatformStatus::Published
&& ($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 PUBLIC_TO_EVERYONE|MUTUAL_FOLLOW_FRIENDS|FOLLOWER_OF_CREATOR|SELF_ONLY (required to publish) + flags (allow_comments, allow_duet, allow_stitch, disclose, brand_content_toggle, brand_organic_toggle, is_aigc, auto_add_music). SELF_ONLY cannot be combined with brand_content_toggle. 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 Shorts: description (optional plain text, at most 5000 bytes). Post content remains the title source, limited to 100 characters. Omit description or pass null to use the post content as description.'),
'meta' => $p->object()->description('Per-platform metadata. Instagram/Facebook: aspect_ratio (1:1|4:5|16:9|original). TikTok: privacy_level PUBLIC_TO_EVERYONE|MUTUAL_FOLLOW_FRIENDS|FOLLOWER_OF_CREATOR|SELF_ONLY (required to publish) + flags (allow_comments, allow_duet, allow_stitch, disclose, brand_content_toggle, brand_organic_toggle, is_aigc, auto_add_music). SELF_ONLY cannot be combined with brand_content_toggle. 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 Shorts: description (optional plain text, at most 5000 bytes). Post content remains the title source, limited to 100 characters. Omit description or pass null to use the post content as description. 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 PUBLIC_TO_EVERYONE|MUTUAL_FOLLOW_FRIENDS|FOLLOWER_OF_CREATOR|SELF_ONLY (required to publish) + flags. SELF_ONLY cannot be combined with brand_content_toggle. 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. YouTube Shorts: description (optional plain text, at most 5000 bytes). Post content remains the title source, limited to 100 characters. Omit description to keep the current override; pass null to remove it and use the post content as description.'),
'meta' => $p->object()->description('Per-platform metadata override. Instagram/Facebook: aspect_ratio. TikTok: privacy_level PUBLIC_TO_EVERYONE|MUTUAL_FOLLOW_FRIENDS|FOLLOWER_OF_CREATOR|SELF_ONLY (required to publish) + flags. SELF_ONLY cannot be combined with brand_content_toggle. 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. YouTube Shorts: description (optional plain text, at most 5000 bytes). Post content remains the title source, limited to 100 characters. Omit description to keep the current override; pass null to remove it and use the post content as description. YouTube/Instagram: first_comment (≤2200, posted by the account right after publish).'),
]))
->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()),
]);
}
}
}
7 changes: 7 additions & 0 deletions app/Support/PostPlatformMetaRules.php
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,11 @@ public static function rules(): array
'platforms.*.meta.brand_content_toggle' => ['sometimes', 'boolean'],
'platforms.*.meta.brand_organic_toggle' => ['sometimes', 'boolean'],

// 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 @@ -105,6 +110,7 @@ public static function messages(): array
'platforms.*.meta.title.max' => __('posts.form.pinterest.title_max'),
'platforms.*.meta.event.end_date.after_or_equal' => __('posts.form.google_business.event_end_date_before_start'),
'platforms.*.meta.event.title.max' => __('posts.form.google_business.title_max'),
'platforms.*.meta.first_comment.max' => __('posts.form.first_comment.max'),
];
}

Expand All @@ -121,6 +127,7 @@ public static function attributes(): array
'platforms.*.meta.link' => __('posts.form.pinterest.link'),
'platforms.*.meta.event.title' => __('posts.form.google_business.event_title'),
'platforms.*.meta.call_to_action.url' => __('posts.form.google_business.cta_url'),
'platforms.*.meta.first_comment' => __('posts.form.first_comment.label'),
];
}

Expand Down
7 changes: 7 additions & 0 deletions lang/ar/posts.php
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,13 @@
'description_invalid' => 'يجب أن يكون وصف YouTube نصًا صالحًا.',
'description_bytes' => ':used / :limit بايت',
],
'first_comment' => [
'label' => 'التعليق الأول',
'placeholder' => 'رابط وإضافات للتعليق الأول…',
'hint' => 'يُنشر باسم قناتك فور نشر الفيديو.',
'hint_instagram' => 'يُنشر فور النشر — المكان الكلاسيكي للروابط التي لا تكون قابلة للنقر في وصف Instagram. أعد ربط الحساب مرة واحدة لمنح إذن التعليقات.',
'max' => 'لا يمكن أن يتجاوز التعليق الأول 2200 حرف.',
],
'pinterest' => [
'settings' => 'إعدادات Pinterest',
'posting_to' => 'النشر إلى',
Expand Down
7 changes: 7 additions & 0 deletions lang/de/posts.php
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,13 @@
'description_invalid' => 'Die YouTube-Beschreibung muss gültiger Text sein.',
'description_bytes' => ':used / :limit Bytes',
],
'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
7 changes: 7 additions & 0 deletions lang/el/posts.php
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,13 @@
'description_invalid' => 'Η περιγραφή YouTube πρέπει να είναι έγκυρο κείμενο.',
'description_bytes' => ':used / :limit byte',
],
'first_comment' => [
'label' => 'Πρώτο σχόλιο',
'placeholder' => 'Σύνδεσμος και πρόσθετα για το πρώτο σχόλιο…',
'hint' => 'Δημοσιεύεται από το κανάλι σας αμέσως μετά τη δημοσίευση του βίντεο.',
'hint_instagram' => 'Δημοσιεύεται αμέσως μετά την ανάρτηση — το κλασικό σημείο για συνδέσμους που οι λεζάντες του Instagram δεν κάνουν κλικαρίσιμους. Επανασυνδέστε τον λογαριασμό μία φορά για να δώσετε το δικαίωμα σχολίων.',
'max' => 'Το πρώτο σχόλιο δεν μπορεί να υπερβαίνει τους 2200 χαρακτήρες.',
],
'pinterest' => [
'settings' => 'Ρυθμίσεις Pinterest',
'posting_to' => 'Δημοσίευση σε',
Expand Down
7 changes: 7 additions & 0 deletions lang/en/posts.php
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,13 @@
'description_invalid' => 'The YouTube description must be valid text.',
'description_bytes' => ':used / :limit bytes',
],
'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
7 changes: 7 additions & 0 deletions lang/es/posts.php
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,13 @@
'description_invalid' => 'La descripción de YouTube debe ser texto válido.',
'description_bytes' => ':used / :limit bytes',
],
'first_comment' => [
'label' => 'Primer comentario',
'placeholder' => 'Enlace y extras para el primer comentario…',
'hint' => 'Se publica desde tu canal justo después de publicar el vídeo.',
'hint_instagram' => 'Se publica justo después de la publicación: el lugar clásico para enlaces que Instagram no hace clicables en la descripción. Reconecta la cuenta una vez para conceder el permiso de comentarios.',
'max' => 'El primer comentario no puede superar los 2200 caracteres.',
],
'pinterest' => [
'settings' => 'Configuración de Pinterest',
'posting_to' => 'Publicando en',
Expand Down
7 changes: 7 additions & 0 deletions lang/fr/posts.php
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,13 @@
'description_invalid' => 'La description YouTube doit être un texte valide.',
'description_bytes' => ':used / :limit octets',
],
'first_comment' => [
'label' => 'Premier commentaire',
'placeholder' => 'Lien et compléments pour le premier commentaire…',
'hint' => 'Publié par votre chaîne juste après la mise en ligne de la vidéo.',
'hint_instagram' => 'Publié juste après la publication — l\'endroit classique pour les liens que les légendes Instagram ne rendent pas cliquables. Reconnectez le compte une fois pour accorder l\'autorisation de commenter.',
'max' => 'Le premier commentaire ne peut pas dépasser 2200 caractères.',
],
'pinterest' => [
'settings' => 'Paramètres Pinterest',
'posting_to' => 'Publier sur',
Expand Down
7 changes: 7 additions & 0 deletions lang/it/posts.php
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,13 @@
'description_invalid' => 'La descrizione YouTube deve essere testo valido.',
'description_bytes' => ':used / :limit byte',
],
'first_comment' => [
'label' => 'Primo commento',
'placeholder' => 'Link ed extra per il primo commento…',
'hint' => 'Pubblicato dal tuo canale subito dopo la pubblicazione del video.',
'hint_instagram' => 'Pubblicato subito dopo il post: il posto classico per i link che le didascalie di Instagram non rendono cliccabili. Ricollega l\'account una volta per concedere il permesso sui commenti.',
'max' => 'Il primo commento non può superare i 2200 caratteri.',
],
'pinterest' => [
'settings' => 'Impostazioni Pinterest',
'posting_to' => 'Pubblicazione su',
Expand Down
7 changes: 7 additions & 0 deletions lang/ja/posts.php
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,13 @@
'description_invalid' => 'YouTubeの説明には有効なテキストを入力してください。',
'description_bytes' => ':used / :limit バイト',
],
'first_comment' => [
'label' => '最初のコメント',
'placeholder' => '最初のコメントに入れるリンクや補足…',
'hint' => '動画公開直後にチャンネルから投稿されます。',
'hint_instagram' => '投稿直後にコメントされます。Instagramのキャプションでクリックできないリンクの定番の置き場所です。コメント権限を付与するには一度アカウントを再連携してください。',
'max' => '最初のコメントは2200文字を超えられません。',
],
'pinterest' => [
'settings' => 'Pinterest 設定',
'posting_to' => '投稿先',
Expand Down
Loading
Loading