2025-07-05 18:26:04 +02:00
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
namespace App\Services\Publishing;
|
|
|
|
|
|
|
|
|
|
use App\Enums\PlatformEnum;
|
|
|
|
|
use App\Exceptions\PublishException;
|
|
|
|
|
use App\Models\Article;
|
|
|
|
|
use App\Models\ArticlePublication;
|
2026-03-18 16:05:31 +01:00
|
|
|
use App\Models\Keyword;
|
2025-07-06 20:37:55 +02:00
|
|
|
use App\Models\PlatformChannel;
|
2026-02-25 23:22:05 +01:00
|
|
|
use App\Models\PlatformChannelPost;
|
2025-08-10 16:18:09 +02:00
|
|
|
use App\Models\Route;
|
2026-03-18 16:05:31 +01:00
|
|
|
use App\Models\RouteArticle;
|
2025-07-05 18:26:04 +02:00
|
|
|
use App\Modules\Lemmy\Services\LemmyPublisher;
|
|
|
|
|
use App\Services\Log\LogSaver;
|
|
|
|
|
use Exception;
|
|
|
|
|
use Illuminate\Support\Collection;
|
|
|
|
|
use RuntimeException;
|
|
|
|
|
|
|
|
|
|
class ArticlePublishingService
|
|
|
|
|
{
|
2026-03-08 14:18:28 +01:00
|
|
|
public function __construct(private LogSaver $logSaver) {}
|
|
|
|
|
|
2025-08-10 01:26:56 +02:00
|
|
|
/**
|
|
|
|
|
* Factory seam to create publisher instances (helps testing without network calls)
|
|
|
|
|
*/
|
|
|
|
|
protected function makePublisher(mixed $account): LemmyPublisher
|
|
|
|
|
{
|
|
|
|
|
return new LemmyPublisher($account);
|
|
|
|
|
}
|
2026-03-08 14:18:28 +01:00
|
|
|
|
2025-07-05 18:26:04 +02:00
|
|
|
/**
|
2026-03-18 16:05:31 +01:00
|
|
|
* Publish an article to the channel specified by a route_article record.
|
|
|
|
|
*
|
|
|
|
|
* @param array<string, mixed> $extractedData
|
|
|
|
|
*
|
|
|
|
|
* @throws PublishException
|
|
|
|
|
*/
|
|
|
|
|
public function publishRouteArticle(RouteArticle $routeArticle, array $extractedData): ?ArticlePublication
|
|
|
|
|
{
|
|
|
|
|
$article = $routeArticle->article;
|
|
|
|
|
$channel = $routeArticle->platformChannel;
|
|
|
|
|
|
|
|
|
|
if (! $channel) {
|
|
|
|
|
throw new PublishException($article, PlatformEnum::LEMMY, new RuntimeException('ROUTE_ARTICLE_MISSING_CHANNEL'));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (! $channel->relationLoaded('platformInstance')) {
|
|
|
|
|
$channel->load(['platformInstance', 'activePlatformAccounts']);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$account = $channel->activePlatformAccounts()->first();
|
|
|
|
|
|
|
|
|
|
if (! $account) {
|
|
|
|
|
$this->logSaver->warning('No active account for channel', $channel, [
|
|
|
|
|
'article_id' => $article->id,
|
|
|
|
|
'route_article_id' => $routeArticle->id,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $this->publishToChannel($article, $extractedData, $channel, $account);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @deprecated Use publishRouteArticle() instead. Kept for PublishApprovedArticleListener compatibility.
|
|
|
|
|
*
|
2026-03-08 14:18:28 +01:00
|
|
|
* @param array<string, mixed> $extractedData
|
2025-08-10 01:26:56 +02:00
|
|
|
* @return Collection<int, ArticlePublication>
|
2026-03-08 14:18:28 +01:00
|
|
|
*
|
2025-07-05 18:26:04 +02:00
|
|
|
* @throws PublishException
|
|
|
|
|
*/
|
2025-08-10 01:26:56 +02:00
|
|
|
public function publishToRoutedChannels(Article $article, array $extractedData): Collection
|
2025-07-05 18:26:04 +02:00
|
|
|
{
|
|
|
|
|
$feed = $article->feed;
|
2025-08-10 01:26:56 +02:00
|
|
|
|
2025-08-10 16:18:09 +02:00
|
|
|
$activeRoutes = Route::where('feed_id', $feed->id)
|
|
|
|
|
->where('is_active', true)
|
|
|
|
|
->get();
|
2025-07-05 18:26:04 +02:00
|
|
|
|
2026-03-18 16:05:31 +01:00
|
|
|
$keywordsByChannel = Keyword::where('feed_id', $feed->id)
|
|
|
|
|
->where('is_active', true)
|
|
|
|
|
->get()
|
|
|
|
|
->groupBy('platform_channel_id');
|
|
|
|
|
|
|
|
|
|
$matchingRoutes = $activeRoutes->filter(function (Route $route) use ($extractedData, $keywordsByChannel) {
|
|
|
|
|
$keywords = $keywordsByChannel->get($route->platform_channel_id, collect());
|
|
|
|
|
if ($keywords->isEmpty()) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$articleContent = ($extractedData['full_article'] ?? '').
|
|
|
|
|
' '.($extractedData['title'] ?? '').
|
|
|
|
|
' '.($extractedData['description'] ?? '');
|
|
|
|
|
|
|
|
|
|
foreach ($keywords as $keyword) {
|
|
|
|
|
if (stripos($articleContent, $keyword->keyword) !== false) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return false;
|
2025-08-10 16:18:09 +02:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return $matchingRoutes->map(function (Route $route) use ($article, $extractedData) {
|
2026-03-18 16:05:31 +01:00
|
|
|
$channel = PlatformChannel::with(['platformInstance', 'activePlatformAccounts'])->find($route->platform_channel_id);
|
|
|
|
|
$account = $channel?->activePlatformAccounts()->first();
|
2025-07-05 18:26:04 +02:00
|
|
|
|
|
|
|
|
if (! $account) {
|
2025-08-15 02:50:42 +02:00
|
|
|
$this->logSaver->warning('No active account for channel', $channel, [
|
2025-08-10 16:18:09 +02:00
|
|
|
'article_id' => $article->id,
|
2026-03-08 14:18:28 +01:00
|
|
|
'route_priority' => $route->priority,
|
2025-07-05 18:26:04 +02:00
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $this->publishToChannel($article, $extractedData, $channel, $account);
|
2026-03-18 16:05:31 +01:00
|
|
|
})->filter();
|
2025-08-10 16:18:09 +02:00
|
|
|
}
|
|
|
|
|
|
2025-07-07 00:51:32 +02:00
|
|
|
/**
|
2026-03-08 14:18:28 +01:00
|
|
|
* @param array<string, mixed> $extractedData
|
2025-07-07 00:51:32 +02:00
|
|
|
*/
|
|
|
|
|
private function publishToChannel(Article $article, array $extractedData, PlatformChannel $channel, mixed $account): ?ArticlePublication
|
2025-07-05 18:26:04 +02:00
|
|
|
{
|
|
|
|
|
try {
|
2026-02-25 23:22:05 +01:00
|
|
|
// Check if this URL or title was already posted to this channel
|
|
|
|
|
$title = $extractedData['title'] ?? $article->title;
|
|
|
|
|
if (PlatformChannelPost::duplicateExists(
|
|
|
|
|
$channel->platformInstance->platform,
|
|
|
|
|
(string) $channel->channel_id,
|
|
|
|
|
$article->url,
|
|
|
|
|
$title
|
|
|
|
|
)) {
|
|
|
|
|
$this->logSaver->info('Skipping duplicate: URL or title already posted to channel', $channel, [
|
|
|
|
|
'article_id' => $article->id,
|
|
|
|
|
'url' => $article->url,
|
|
|
|
|
'title' => $title,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-10 01:26:56 +02:00
|
|
|
$publisher = $this->makePublisher($account);
|
2025-07-05 18:26:04 +02:00
|
|
|
$postData = $publisher->publishToChannel($article, $extractedData, $channel);
|
|
|
|
|
|
|
|
|
|
$publication = ArticlePublication::create([
|
|
|
|
|
'article_id' => $article->id,
|
|
|
|
|
'post_id' => $postData['post_view']['post']['id'],
|
2025-07-06 01:35:59 +02:00
|
|
|
'platform_channel_id' => $channel->id,
|
2025-07-05 18:26:04 +02:00
|
|
|
'published_by' => $account->username,
|
|
|
|
|
'published_at' => now(),
|
|
|
|
|
'platform' => $channel->platformInstance->platform->value,
|
|
|
|
|
'publication_data' => $postData,
|
|
|
|
|
]);
|
|
|
|
|
|
2026-03-18 16:05:31 +01:00
|
|
|
$this->logSaver->info('Published to channel', $channel, [
|
2026-03-08 14:18:28 +01:00
|
|
|
'article_id' => $article->id,
|
2025-07-05 18:26:04 +02:00
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
return $publication;
|
|
|
|
|
} catch (Exception $e) {
|
2025-08-15 02:50:42 +02:00
|
|
|
$this->logSaver->warning('Failed to publish to channel', $channel, [
|
2025-07-05 18:26:04 +02:00
|
|
|
'article_id' => $article->id,
|
2026-03-08 14:18:28 +01:00
|
|
|
'error' => $e->getMessage(),
|
2025-07-05 18:26:04 +02:00
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|