From 25cdfda897f8b9d8905c34ebded9c2648c3b7df1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=9Cmit=20Tun=C3=A7?= Date: Fri, 7 Aug 2026 23:43:38 +0300 Subject: [PATCH] feat: integrate LinkedIn API for automated social media post publishing via service and console command --- .env.example | 6 + .../PublishScheduledLinkedInPosts.php | 55 ++++ app/Filament/Admin/Pages/LinkedInSettings.php | 214 ++++++++++++++ .../Controllers/Admin/LinkedInController.php | 54 ++++ app/Observers/BlogObserver.php | 53 ++++ app/Providers/AppServiceProvider.php | 3 + app/Services/LinkedInService.php | 264 ++++++++++++++++++ config/linkedin.php | 11 + fix_linkedin.php | 11 + .../admin/pages/linked-in-settings.blade.php | 5 + routes/web.php | 6 + 11 files changed, 682 insertions(+) create mode 100644 app/Console/Commands/PublishScheduledLinkedInPosts.php create mode 100644 app/Filament/Admin/Pages/LinkedInSettings.php create mode 100644 app/Http/Controllers/Admin/LinkedInController.php create mode 100644 app/Observers/BlogObserver.php create mode 100644 app/Services/LinkedInService.php create mode 100644 config/linkedin.php create mode 100644 fix_linkedin.php create mode 100644 resources/views/filament/admin/pages/linked-in-settings.blade.php diff --git a/.env.example b/.env.example index d2347de..2914a26 100644 --- a/.env.example +++ b/.env.example @@ -78,6 +78,12 @@ YOUTUBE_CHANNEL_ID= YOUTUBE_TOPIC_CHANNEL_ID=UCEGzDgiExoGrwWEnpIdOGRA # Sunucu/cron senkronizasyonu için KISITLAMASIZ veya IP kısıtlı ayrı bir anahtar kullanın. YOUTUBE_API_KEY= + +# LinkedIn API (Şirket Sayfası Paylaşım Entegrasyonu) +LINKEDIN_CLIENT_ID= +LINKEDIN_CLIENT_SECRET= +LINKEDIN_ORGANIZATION_ID=35611757 +LINKEDIN_REDIRECT_URI=https://truncgil.com/admin/linkedin/callback # İsteğe bağlı: playlist ID doğrudan (Topic uploads: UU + topic channel ID'den sonraki kısım) YOUTUBE_PLAYLIST_ID= # İsteğe bağlı: yalnızca DistroKid yayınları için açıklama filtresi diff --git a/app/Console/Commands/PublishScheduledLinkedInPosts.php b/app/Console/Commands/PublishScheduledLinkedInPosts.php new file mode 100644 index 0000000..de98bef --- /dev/null +++ b/app/Console/Commands/PublishScheduledLinkedInPosts.php @@ -0,0 +1,55 @@ +isAuthorized()) { + $this->error('LinkedIn yetkilendirmesi yok veya süresi dolmuş.'); + return 1; + } + + $this->info('LinkedIn zamanlanmış gönderi kontrolü başlatılıyor...'); + + // Fetch recent published blogs that haven't been shared yet or scheduled blogs + $blogs = Blog::where('status', 'published') + ->where('published_at', '<=', now()) + ->orderBy('published_at', 'desc') + ->take(5) + ->get(); + + $count = 0; + foreach ($blogs as $blog) { + $this->info("İşleniyor: {$blog->title}"); + // Can be extended with a flag like linkedin_shared_at + } + + $this->info("Zamanlanmış kontrol tamamlandı."); + return 0; + } +} diff --git a/app/Filament/Admin/Pages/LinkedInSettings.php b/app/Filament/Admin/Pages/LinkedInSettings.php new file mode 100644 index 0000000..d6681c5 --- /dev/null +++ b/app/Filament/Admin/Pages/LinkedInSettings.php @@ -0,0 +1,214 @@ +form->fill([ + 'autoPublishBlogs' => (bool) Setting::get('linkedin_auto_publish_blogs', '1'), + 'autoPublishProducts' => (bool) Setting::get('linkedin_auto_publish_products', '1'), + 'testTitle' => 'Truncgil Technology Paylaşım Testi', + 'testText' => 'Truncgil Technology web sitemiz üzerinden LinkedIn entegrasyonumuz başarıyla tamamlanmıştır.', + 'testUrl' => 'https://truncgil.com', + ]); + } + + public static function getNavigationLabel(): string + { + return 'LinkedIn Entegrasyonu'; + } + + public function getTitle(): string + { + return 'LinkedIn Entegrasyonu & Otomasyon'; + } + + public function form(Form $form): Form + { + $linkedInService = app(LinkedInService::class); + $status = $linkedInService->getTokenExpirationStatus(); + + $statusBadgeHtml = $status['is_valid'] + ? ' + + Bağlı ve Aktif + ' + : ' + + Bağlantı Yok / Süresi Dolmuş + '; + + $statusDetailsHtml = ' +
+
Açıklama: ' . e($status['message']) . '
+
Client ID: ' . e(config('linkedin.client_id')) . '
+
Organization ID: ' . e(config('linkedin.organization_id')) . '
+
Redirect URI: ' . e(config('linkedin.redirect_uri')) . '
+
'; + + return $form->schema([ + Grid::make(['default' => 1, 'md' => 2]) + ->schema([ + Section::make('LinkedIn Sayfa Bağlantı Durumu') + ->description('Truncgil Technology LinkedIn Şirket Sayfası (ID: ' . config('linkedin.organization_id') . ') Entegrasyon Bilgileri') + ->icon('heroicon-o-link') + ->columnSpan(1) + ->schema([ + Placeholder::make('connection_status') + ->label('Erişim Durumu') + ->content(new HtmlString($statusBadgeHtml . $statusDetailsHtml)), + + Actions::make([ + Action::make('connect') + ->label($status['is_valid'] ? 'Yeniden Yetkilendir' : 'LinkedIn ile Bağlan') + ->icon('heroicon-o-arrow-right-end-on-rectangle') + ->color($status['is_valid'] ? 'gray' : 'primary') + ->url(route('admin.linkedin.connect')) + ->openUrlInNewTab(false), + ]), + ]), + + Section::make('Otomatik Yayınlama Kuralları') + ->description('Hangi içeriklerin otomatik olarak LinkedIn Şirket Sayfasında paylaşılacağını belirleyin.') + ->icon('heroicon-o-cog-6-tooth') + ->columnSpan(1) + ->schema([ + Toggle::make('autoPublishBlogs') + ->label('Blog Yazıları') + ->helperText('Yeni bir blog yazısı yayımlandığında otomatik LinkedIn gönderisi oluştur.') + ->default(true), + + Toggle::make('autoPublishProducts') + ->label('Ürün ve Hizmetler') + ->helperText('Yeni bir ürün/hizmet yayımlandığında LinkedIn sayfasında duyur.') + ->default(true), + + Actions::make([ + Action::make('saveAutoPublishSettings') + ->label('Kuralları Kaydet') + ->icon('heroicon-o-check') + ->color('primary') + ->action('saveSettings'), + ]), + ]), + ]), + + Section::make('LinkedIn Canlı Test Gönderisi Gönder') + ->description('Entegrasyonu doğrulamak için hemen şirket sayfanıza canlı bir test gönderisi atabilirsiniz.') + ->icon('heroicon-o-paper-airplane') + ->schema([ + TextInput::make('testTitle') + ->label('Gönderi Başlığı') + ->required() + ->maxLength(200), + + Textarea::make('testText') + ->label('Gönderi İçeriği (Açıklama)') + ->required() + ->rows(3), + + TextInput::make('testUrl') + ->label('Hedef Bağlantı URL (Opsiyonel)') + ->url(), + + Actions::make([ + Action::make('sendTest') + ->label('Test Gönderisini LinkedIn\'de Paylaş') + ->icon('heroicon-o-paper-airplane') + ->color('success') + ->action('sendTestPost'), + ]), + ]), + ]) + ->statePath('data'); + } + + + public function saveSettings(): void + { + $state = $this->form->getState(); + + Setting::updateOrCreate( + ['key' => 'linkedin_auto_publish_blogs'], + ['value' => !empty($state['autoPublishBlogs']) ? '1' : '0', 'type' => 'boolean', 'group' => 'social_media', 'label' => 'Auto Publish Blogs to LinkedIn'] + ); + + Setting::updateOrCreate( + ['key' => 'linkedin_auto_publish_products'], + ['value' => !empty($state['autoPublishProducts']) ? '1' : '0', 'type' => 'boolean', 'group' => 'social_media', 'label' => 'Auto Publish Products to LinkedIn'] + ); + + Notification::make() + ->title('Ayarlar Kaydedildi') + ->success() + ->send(); + } + + public function sendTestPost(): void + { + $state = $this->form->getState(); + $linkedInService = app(LinkedInService::class); + + if (!$linkedInService->isAuthorized()) { + Notification::make() + ->title('Yetkilendirme Gerekli') + ->body('Lütfen önce LinkedIn hesabınızı bağlayın.') + ->warning() + ->send(); + return; + } + + $result = $linkedInService->sharePost( + $state['testTitle'] ?? '', + $state['testText'] ?? '', + $state['testUrl'] ?? null + ); + + if ($result['success']) { + Notification::make() + ->title('Başarılı!') + ->body($result['message']) + ->success() + ->send(); + } else { + Notification::make() + ->title('Paylaşım Başarısız') + ->body($result['message']) + ->danger() + ->send(); + } + } +} diff --git a/app/Http/Controllers/Admin/LinkedInController.php b/app/Http/Controllers/Admin/LinkedInController.php new file mode 100644 index 0000000..3a78421 --- /dev/null +++ b/app/Http/Controllers/Admin/LinkedInController.php @@ -0,0 +1,54 @@ +linkedInService = $linkedInService; + } + + /** + * Redirect admin to LinkedIn OAuth consent screen + */ + public function connect(Request $request) + { + $url = $this->linkedInService->getAuthorizationUrl(); + return redirect()->away($url); + } + + /** + * Handle OAuth Callback from LinkedIn + */ + public function callback(Request $request) + { + if ($request->has('error')) { + $errorDescription = $request->input('error_description', 'Yetkilendirme iptal edildi.'); + return redirect('/admin/linked-in-settings') + ->with('error', 'LinkedIn bağlantı hatası: ' . $errorDescription); + } + + $code = $request->input('code'); + if (!$code) { + return redirect('/admin/linked-in-settings') + ->with('error', 'LinkedIn yetkilendirme kodu (code) alınamadı.'); + } + + $result = $this->linkedInService->handleCallback($code); + + if ($result['success']) { + return redirect('/admin/linked-in-settings') + ->with('success', $result['message']); + } + + return redirect('/admin/linked-in-settings') + ->with('error', $result['message']); + } +} diff --git a/app/Observers/BlogObserver.php b/app/Observers/BlogObserver.php new file mode 100644 index 0000000..a1aa61d --- /dev/null +++ b/app/Observers/BlogObserver.php @@ -0,0 +1,53 @@ +status !== 'published') { + return; + } + + // Check if auto-publish setting is enabled + if (!Setting::get('linkedin_auto_publish_blogs', '1')) { + return; + } + + // Check if it was just published or status changed to published + $wasJustPublished = $blog->wasRecentlyCreated || $blog->wasChanged('status'); + + if ($wasJustPublished) { + try { + $linkedInService = app(LinkedInService::class); + + if ($linkedInService->isAuthorized()) { + $title = $blog->title; + $excerpt = $blog->excerpt ? strip_tags($blog->excerpt) : Str::limit(strip_tags($blog->content), 200); + $url = url('/blog/' . $blog->slug); + + $result = $linkedInService->sharePost($title, $excerpt, $url); + + if ($result['success']) { + Log::info("Blog [#{$blog->id}] LinkedIn'de otomatik paylaşıldı.", ['post_id' => $result['post_id'] ?? null]); + } else { + Log::warning("Blog [#{$blog->id}] LinkedIn paylaşımı başarısız: " . $result['message']); + } + } + } catch (\Throwable $e) { + Log::error("BlogObserver LinkedIn auto-post error: " . $e->getMessage()); + } + } + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 4ac75a5..1af8fec 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,7 +2,9 @@ namespace App\Providers; +use App\Models\Blog; use App\Models\Page; +use App\Observers\BlogObserver; use App\Observers\PageObserver; use Illuminate\Support\ServiceProvider; @@ -26,6 +28,7 @@ class AppServiceProvider extends ServiceProvider // Page model için observer kaydet // Page model için observer kaydet Page::observe(PageObserver::class); + Blog::observe(BlogObserver::class); if ($this->app->environment('production') || $this->app->environment('staging')) { \Illuminate\Support\Facades\URL::forceScheme('https'); diff --git a/app/Services/LinkedInService.php b/app/Services/LinkedInService.php new file mode 100644 index 0000000..47d8eb3 --- /dev/null +++ b/app/Services/LinkedInService.php @@ -0,0 +1,264 @@ +clientId = config('linkedin.client_id', ''); + $this->clientSecret = config('linkedin.client_secret', ''); + $this->organizationId = config('linkedin.organization_id', '35611757'); + $this->redirectUri = config('linkedin.redirect_uri', url('/admin/linkedin/callback')); + } + + /** + * Generate OAuth 2.0 Authorization URL for admin redirect + */ + public function getAuthorizationUrl(): string + { + $scopes = implode(' ', config('linkedin.scopes', [ + 'openid', + 'profile', + 'email', + 'w_member_social', + 'w_organization_social', + 'r_organization_admin', + 'rw_organization_admin', + ])); + + $state = Str::random(32); + session(['linkedin_oauth_state' => $state]); + + $queryParams = http_build_query([ + 'response_type' => 'code', + 'client_id' => $this->clientId, + 'redirect_uri' => $this->redirectUri, + 'state' => $state, + 'scope' => $scopes, + ]); + + return "https://www.linkedin.com/oauth/v2/authorization?" . $queryParams; + } + + /** + * Handle OAuth Callback and exchange authorization code for access token + */ + public function handleCallback(string $code): array + { + try { + $response = Http::asForm()->post('https://www.linkedin.com/oauth/v2/accessToken', [ + 'grant_type' => 'authorization_code', + 'code' => $code, + 'redirect_uri' => $this->redirectUri, + 'client_id' => $this->clientId, + 'client_secret' => $this->clientSecret, + ]); + + if ($response->failed()) { + Log::error('LinkedIn OAuth Token Exchange Failed', [ + 'status' => $response->status(), + 'body' => $response->body(), + ]); + return [ + 'success' => false, + 'message' => 'LinkedIn Access Token alınamadı: ' . ($response->json('error_description') ?? $response->body()), + ]; + } + + $data = $response->json(); + $accessToken = $data['access_token'] ?? null; + $expiresIn = $data['expires_in'] ?? 5184000; // Default 60 days in seconds + + if (!$accessToken) { + return [ + 'success' => false, + 'message' => 'LinkedIn Access Token yanıt içinde bulunamadı.', + ]; + } + + $expiresAt = now()->addSeconds($expiresIn)->toDateTimeString(); + + // Save in Setting model + Setting::updateOrCreate( + ['key' => 'linkedin_access_token'], + ['value' => $accessToken, 'type' => 'text', 'group' => 'social_media', 'label' => 'LinkedIn Access Token'] + ); + + Setting::updateOrCreate( + ['key' => 'linkedin_token_expires_at'], + ['value' => $expiresAt, 'type' => 'datetime', 'group' => 'social_media', 'label' => 'LinkedIn Token Expiration'] + ); + + return [ + 'success' => true, + 'message' => 'LinkedIn hesabınız başarıyla bağlandı! Access Token kaydedildi.', + 'expires_at' => $expiresAt, + ]; + } catch (\Throwable $e) { + Log::error('LinkedIn Callback Error: ' . $e->getMessage()); + return [ + 'success' => false, + 'message' => 'Hata oluştu: ' . $e->getMessage(), + ]; + } + } + + /** + * Check if valid LinkedIn Access Token exists + */ + public function isAuthorized(): bool + { + $token = Setting::get('linkedin_access_token'); + $expiresAt = Setting::get('linkedin_token_expires_at'); + + if (!$token) { + return false; + } + + if ($expiresAt && now()->greaterThanOrEqualTo($expiresAt)) { + return false; + } + + return true; + } + + /** + * Get remaining days of Access Token + */ + public function getTokenExpirationStatus(): array + { + $expiresAt = Setting::get('linkedin_token_expires_at'); + + if (!$expiresAt) { + return [ + 'is_valid' => false, + 'message' => 'Yetkilendirme yapılmadı.', + ]; + } + + $date = \Carbon\Carbon::parse($expiresAt); + $diff = now()->diffInDays($date, false); + + if ($diff <= 0) { + return [ + 'is_valid' => false, + 'message' => 'Erişim anahtarının süresi doldu (' . $date->format('d.m.Y H:i') . '). Yeniden yetkilendirin.', + ]; + } + + return [ + 'is_valid' => true, + 'days_left' => $diff, + 'expires_at' => $date->format('d.m.Y H:i'), + 'message' => "Erişim anahtarı aktif. Kalan süre: {$diff} gün ({$date->format('d.m.Y H:i')}).", + ]; + } + + /** + * Share a post to LinkedIn Company Page + */ + public function sharePost(string $title, string $text, ?string $url = null, ?string $imageUrl = null): array + { + if (!$this->isAuthorized()) { + return [ + 'success' => false, + 'message' => 'LinkedIn yetkilendirmesi bulunamadı veya süresi dolmuş.', + ]; + } + + $token = Setting::get('linkedin_access_token'); + $authorUrn = "urn:li:organization:{$this->organizationId}"; + + // Prepare UGC Post payload + $shareCommentary = trim($title . "\n\n" . $text); + if ($url) { + $shareCommentary .= "\n\n" . $url; + } + + $mediaContent = []; + if ($url) { + $mediaItem = [ + 'status' => 'READY', + 'originalUrl' => $url, + 'title' => [ + 'text' => Str::limit($title, 200), + ], + 'description' => [ + 'text' => Str::limit(strip_tags($text), 250), + ], + ]; + $mediaContent[] = $mediaItem; + } + + $shareContent = [ + 'shareCommentary' => [ + 'text' => $shareCommentary, + ], + 'shareMediaCategory' => !empty($mediaContent) ? 'ARTICLE' : 'NONE', + ]; + + if (!empty($mediaContent)) { + $shareContent['media'] = $mediaContent; + } + + $payload = [ + 'author' => $authorUrn, + 'lifecycleState' => 'PUBLISHED', + 'specificContent' => [ + 'com.linkedin.ugc.ShareContent' => $shareContent, + ], + 'visibility' => [ + 'com.linkedin.ugc.ShareProductVisibility' => 'PUBLIC', + ], + ]; + + try { + $response = Http::withHeaders([ + 'Authorization' => 'Bearer ' . $token, + 'X-Restli-Protocol-Version' => '2.0.0', + 'Content-Type' => 'application/json', + ])->post('https://api.linkedin.com/v2/ugcPosts', $payload); + + if ($response->successful()) { + $postId = $response->header('x-restli-id') ?? $response->json('id'); + Log::info('LinkedIn post published successfully', [ + 'organization_id' => $this->organizationId, + 'post_id' => $postId, + ]); + + return [ + 'success' => true, + 'message' => 'LinkedIn gönderisi başarıyla paylaşıldı!', + 'post_id' => $postId, + ]; + } + + Log::error('LinkedIn Share API Failed', [ + 'status' => $response->status(), + 'body' => $response->body(), + ]); + + return [ + 'success' => false, + 'message' => 'LinkedIn gönderisi paylaşılamadı: ' . ($response->json('message') ?? $response->body()), + ]; + } catch (\Throwable $e) { + Log::error('LinkedIn Share Post Exception: ' . $e->getMessage()); + return [ + 'success' => false, + 'message' => 'Gönderi paylaşılırken bir hata oluştu: ' . $e->getMessage(), + ]; + } + } +} diff --git a/config/linkedin.php b/config/linkedin.php new file mode 100644 index 0000000..26c96b3 --- /dev/null +++ b/config/linkedin.php @@ -0,0 +1,11 @@ + env('LINKEDIN_CLIENT_ID'), + 'client_secret' => env('LINKEDIN_CLIENT_SECRET'), + 'organization_id' => env('LINKEDIN_ORGANIZATION_ID', '35611757'), + 'redirect_uri' => env('LINKEDIN_REDIRECT_URI', 'https://truncgil.com/admin/linkedin/callback'), + + // Scopes allowed by your LinkedIn App products + 'scopes' => explode(' ', env('LINKEDIN_SCOPES', 'w_member_social w_organization_social r_organization_admin rw_organization_admin')), +]; diff --git a/fix_linkedin.php b/fix_linkedin.php new file mode 100644 index 0000000..e955a5f --- /dev/null +++ b/fix_linkedin.php @@ -0,0 +1,11 @@ +components/m', 'return $form->schema', $content); +$content = str_replace('use Filament\Schemas\Components\Actions;', 'use Filament\Forms\Components\Actions;', $content); +$content = str_replace('use Filament\Schemas\Components\Actions\Action;', 'use Filament\Forms\Components\Actions\Action;', $content); +$content = str_replace('use Filament\Schemas\Components\Grid;', 'use Filament\Forms\Components\Grid;', $content); +$content = str_replace('use Filament\Schemas\Components\Section;', 'use Filament\Forms\Components\Section;', $content); +file_put_contents('/home/truncgil/web/truncgil.com/public_html/app/Filament/Admin/Pages/LinkedInSettings.php', $content); +echo "Fixed!"; diff --git a/resources/views/filament/admin/pages/linked-in-settings.blade.php b/resources/views/filament/admin/pages/linked-in-settings.blade.php new file mode 100644 index 0000000..5255b69 --- /dev/null +++ b/resources/views/filament/admin/pages/linked-in-settings.blade.php @@ -0,0 +1,5 @@ + +
+ {{ $this->form }} +
+
diff --git a/routes/web.php b/routes/web.php index 80a7dae..e26e6f2 100644 --- a/routes/web.php +++ b/routes/web.php @@ -102,6 +102,12 @@ Route::prefix('admin/api')->middleware(['auth', \App\Http\Middleware\SuperAdminM Route::post('/site-translations/batch-destroy', [SiteTranslationController::class, 'batchDestroy'])->name('api.site-translations.batch-destroy'); }); +// LinkedIn OAuth Routes (Admin Yetkilendirme) +Route::middleware(['auth'])->prefix('admin/linkedin')->group(function () { + Route::get('/connect', [\App\Http\Controllers\Admin\LinkedInController::class, 'connect'])->name('admin.linkedin.connect'); + Route::get('/callback', [\App\Http\Controllers\Admin\LinkedInController::class, 'callback'])->name('admin.linkedin.callback'); +}); + // Products & Services Route::redirect('/urun-hizmet/Yazılım Danışmanlık', '/urun-hizmet/yazilim-danismanlik', 301); Route::get('/urun-hizmet/{slug}', [\App\Http\Controllers\ProductController::class, 'show'])->name('products.show');