fedi-feed-router/backend/app/Services/Publishing/ArticlePublishingService.php
2025-08-15 02:58:14 +02:00

143 lines
4.9 KiB
PHP

<?php
namespace App\Services\Publishing;
use App\Enums\PlatformEnum;
use App\Exceptions\PublishException;
use App\Models\Article;
use App\Models\ArticlePublication;
use App\Models\PlatformChannel;
use App\Models\Route;
use App\Modules\Lemmy\Services\LemmyPublisher;
use App\Services\Log\LogSaver;
use Exception;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Support\Collection;
use RuntimeException;
class ArticlePublishingService
{
public function __construct(private LogSaver $logSaver)
{
}
/**
* Factory seam to create publisher instances (helps testing without network calls)
*/
protected function makePublisher(mixed $account): LemmyPublisher
{
return new LemmyPublisher($account);
}
/**
* @param array<string, mixed> $extractedData
* @return Collection<int, ArticlePublication>
* @throws PublishException
*/
public function publishToRoutedChannels(Article $article, array $extractedData): Collection
{
if (! $article->isValid()) {
throw new PublishException($article, PlatformEnum::LEMMY, new RuntimeException('CANNOT_PUBLISH_INVALID_ARTICLE'));
}
$feed = $article->feed;
// Get active routes with keywords instead of just channels
$activeRoutes = Route::where('feed_id', $feed->id)
->where('is_active', true)
->with(['platformChannel.platformInstance', 'platformChannel.activePlatformAccounts', 'keywords'])
->orderBy('priority', 'desc')
->get();
// Filter routes based on keyword matches
$matchingRoutes = $activeRoutes->filter(function (Route $route) use ($extractedData) {
return $this->routeMatchesArticle($route, $extractedData);
});
return $matchingRoutes->map(function (Route $route) use ($article, $extractedData) {
$channel = $route->platformChannel;
$account = $channel->activePlatformAccounts()->first();
if (! $account) {
$this->logSaver->warning('No active account for channel', $channel, [
'article_id' => $article->id,
'route_priority' => $route->priority
]);
return null;
}
return $this->publishToChannel($article, $extractedData, $channel, $account);
})
->filter();
}
/**
* Check if a route matches an article based on keywords
* @param array<string, mixed> $extractedData
*/
private function routeMatchesArticle(Route $route, array $extractedData): bool
{
// Get active keywords for this route
$activeKeywords = $route->keywords->where('is_active', true);
// If no keywords are defined for this route, the route matches any article
if ($activeKeywords->isEmpty()) {
return true;
}
// Get article content for keyword matching
$articleContent = '';
if (isset($extractedData['full_article'])) {
$articleContent = $extractedData['full_article'];
}
if (isset($extractedData['title'])) {
$articleContent .= ' ' . $extractedData['title'];
}
if (isset($extractedData['description'])) {
$articleContent .= ' ' . $extractedData['description'];
}
// Check if any of the route's keywords match the article content
foreach ($activeKeywords as $keywordModel) {
$keyword = $keywordModel->keyword;
if (stripos($articleContent, $keyword) !== false) {
return true;
}
}
return false;
}
/**
* @param array<string, mixed> $extractedData
*/
private function publishToChannel(Article $article, array $extractedData, PlatformChannel $channel, mixed $account): ?ArticlePublication
{
try {
$publisher = $this->makePublisher($account);
$postData = $publisher->publishToChannel($article, $extractedData, $channel);
$publication = ArticlePublication::create([
'article_id' => $article->id,
'post_id' => $postData['post_view']['post']['id'],
'platform_channel_id' => $channel->id,
'published_by' => $account->username,
'published_at' => now(),
'platform' => $channel->platformInstance->platform->value,
'publication_data' => $postData,
]);
$this->logSaver->info('Published to channel via keyword-filtered routing', $channel, [
'article_id' => $article->id
]);
return $publication;
} catch (Exception $e) {
$this->logSaver->warning('Failed to publish to channel', $channel, [
'article_id' => $article->id,
'error' => $e->getMessage()
]);
return null;
}
}
}