BloomFeed Addons v0.22
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Notifier;
|
||||
use App\Services\NotifierDispatchService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class NotifierController extends Controller
|
||||
{
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$notifiers = $request->user()->notifiers()->with('feeds')->orderBy('created_at')->get();
|
||||
$feeds = $request->user()->feeds()->orderBy('title')->get();
|
||||
|
||||
return view('notifiers', [
|
||||
'notifiers' => $notifiers,
|
||||
'feeds' => $feeds,
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$validated = $this->validatePayload($request);
|
||||
|
||||
$notifier = $request->user()->notifiers()->create([
|
||||
'type' => $validated['type'],
|
||||
'name' => $validated['name'],
|
||||
'url' => $validated['url'],
|
||||
'scope' => $validated['scope'],
|
||||
]);
|
||||
|
||||
if ($validated['scope'] === Notifier::SCOPE_FEEDS) {
|
||||
$notifier->feeds()->sync($validated['feed_ids'] ?? []);
|
||||
}
|
||||
|
||||
return redirect()->route('notifiers')->with('status', __('app.notifier_created'));
|
||||
}
|
||||
|
||||
public function update(Request $request, Notifier $notifier): RedirectResponse
|
||||
{
|
||||
$this->authorize('update', $notifier);
|
||||
|
||||
$validated = $this->validatePayload($request);
|
||||
|
||||
$notifier->update([
|
||||
'type' => $validated['type'],
|
||||
'name' => $validated['name'],
|
||||
'url' => $validated['url'],
|
||||
'scope' => $validated['scope'],
|
||||
]);
|
||||
|
||||
$notifier->feeds()->sync($validated['scope'] === Notifier::SCOPE_FEEDS ? ($validated['feed_ids'] ?? []) : []);
|
||||
|
||||
return redirect()->route('notifiers')->with('status', __('app.notifier_updated'));
|
||||
}
|
||||
|
||||
public function toggle(Request $request, Notifier $notifier): RedirectResponse
|
||||
{
|
||||
$this->authorize('update', $notifier);
|
||||
|
||||
$notifier->update(['enabled' => ! $notifier->enabled]);
|
||||
|
||||
return redirect()->route('notifiers');
|
||||
}
|
||||
|
||||
public function test(Notifier $notifier, NotifierDispatchService $dispatcher): RedirectResponse
|
||||
{
|
||||
$this->authorize('update', $notifier);
|
||||
|
||||
$ok = $dispatcher->sendTest($notifier);
|
||||
|
||||
return redirect()->route('notifiers')
|
||||
->with('status', $ok ? __('app.notifier_test_ok') : __('app.notifier_test_failed'));
|
||||
}
|
||||
|
||||
public function destroy(Notifier $notifier): RedirectResponse
|
||||
{
|
||||
$this->authorize('delete', $notifier);
|
||||
|
||||
$notifier->delete();
|
||||
|
||||
return redirect()->route('notifiers')->with('status', __('app.notifier_deleted'));
|
||||
}
|
||||
|
||||
private function validatePayload(Request $request): array
|
||||
{
|
||||
return $request->validate([
|
||||
'type' => ['required', 'in:'.Notifier::TYPE_DISCORD.','.Notifier::TYPE_WEBHOOK],
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'url' => ['required', 'url', 'max:2048'],
|
||||
'scope' => ['required', 'in:'.Notifier::SCOPE_ALL.','.Notifier::SCOPE_FEEDS],
|
||||
'feed_ids' => ['array'],
|
||||
'feed_ids.*' => [
|
||||
'integer',
|
||||
Rule::exists('feeds', 'id')->where('user_id', $request->user()->id),
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
|
||||
#[Fillable(['type', 'name', 'url', 'scope', 'enabled', 'last_triggered_at', 'last_error'])]
|
||||
class Notifier extends Model
|
||||
{
|
||||
public const TYPE_DISCORD = 'discord';
|
||||
|
||||
public const TYPE_WEBHOOK = 'webhook';
|
||||
|
||||
public const SCOPE_ALL = 'all';
|
||||
|
||||
public const SCOPE_FEEDS = 'feeds';
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'enabled' => 'boolean',
|
||||
'last_triggered_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function feeds(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Feed::class);
|
||||
}
|
||||
|
||||
public function scopeEnabled(Builder $query): Builder
|
||||
{
|
||||
return $query->where('enabled', true);
|
||||
}
|
||||
|
||||
public function scopeForFeed(Builder $query, int $feedId): Builder
|
||||
{
|
||||
return $query->where(function (Builder $q) use ($feedId) {
|
||||
$q->where('scope', self::SCOPE_ALL)
|
||||
->orWhere(function (Builder $q2) use ($feedId) {
|
||||
$q2->where('scope', self::SCOPE_FEEDS)
|
||||
->whereHas('feeds', fn (Builder $f) => $f->where('feeds.id', $feedId));
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,11 @@ class User extends Authenticatable
|
||||
return $this->hasMany(Feed::class);
|
||||
}
|
||||
|
||||
public function notifiers(): HasMany
|
||||
{
|
||||
return $this->hasMany(Notifier::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the attributes that should be cast.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\Notifier;
|
||||
use App\Models\User;
|
||||
|
||||
class NotifierPolicy
|
||||
{
|
||||
public function update(User $user, Notifier $notifier): bool
|
||||
{
|
||||
return $user->id === $notifier->user_id;
|
||||
}
|
||||
|
||||
public function delete(User $user, Notifier $notifier): bool
|
||||
{
|
||||
return $user->id === $notifier->user_id;
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,10 @@ use Throwable;
|
||||
|
||||
class FeedFetcherService
|
||||
{
|
||||
public function __construct(private readonly NotifierDispatchService $notifier)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch, parse and store new articles for a single feed.
|
||||
*
|
||||
@@ -66,6 +70,8 @@ class FeedFetcherService
|
||||
$article->feed()->associate($feed);
|
||||
$article->save();
|
||||
|
||||
$this->notifier->notifyNewArticle($article);
|
||||
|
||||
$newCount++;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Article;
|
||||
use App\Models\Notifier;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
use Throwable;
|
||||
|
||||
class NotifierDispatchService
|
||||
{
|
||||
/**
|
||||
* Notify every enabled notifier of the article's owner that matches this
|
||||
* article's feed (scope "all", or scope "feeds" including this feed).
|
||||
*/
|
||||
public function notifyNewArticle(Article $article): void
|
||||
{
|
||||
$feed = $article->feed;
|
||||
|
||||
$notifiers = Notifier::query()
|
||||
->where('user_id', $feed->user_id)
|
||||
->enabled()
|
||||
->forFeed($feed->id)
|
||||
->get();
|
||||
|
||||
foreach ($notifiers as $notifier) {
|
||||
$this->send($notifier, $article);
|
||||
}
|
||||
}
|
||||
|
||||
public function sendTest(Notifier $notifier): bool
|
||||
{
|
||||
$payload = $notifier->type === Notifier::TYPE_DISCORD
|
||||
? $this->discordTestPayload()
|
||||
: $this->webhookTestPayload();
|
||||
|
||||
return $this->deliver($notifier, $payload);
|
||||
}
|
||||
|
||||
private function send(Notifier $notifier, Article $article): void
|
||||
{
|
||||
$payload = $notifier->type === Notifier::TYPE_DISCORD
|
||||
? $this->discordPayload($notifier, $article)
|
||||
: $this->webhookPayload($notifier, $article);
|
||||
|
||||
$this->deliver($notifier, $payload);
|
||||
}
|
||||
|
||||
private function deliver(Notifier $notifier, array $payload): bool
|
||||
{
|
||||
try {
|
||||
$response = Http::timeout(6)->post($notifier->url, $payload);
|
||||
|
||||
if ($response->failed()) {
|
||||
$notifier->update([
|
||||
'last_error' => Str::limit("HTTP {$response->status()}", 250, ''),
|
||||
]);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$notifier->update(['last_triggered_at' => now(), 'last_error' => null]);
|
||||
|
||||
return true;
|
||||
} catch (Throwable $e) {
|
||||
Log::warning('Notifier delivery failed', [
|
||||
'notifier_id' => $notifier->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
$notifier->update(['last_error' => Str::limit($e->getMessage(), 250, '')]);
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private function discordPayload(Notifier $notifier, Article $article): array
|
||||
{
|
||||
return [
|
||||
'embeds' => [[
|
||||
'title' => Str::limit($article->title, 250),
|
||||
'url' => $article->link,
|
||||
'description' => $article->excerpt ? Str::limit($article->excerpt, 300) : null,
|
||||
'color' => 15768330, // amber accent
|
||||
'footer' => ['text' => $article->feed->displayTitle()],
|
||||
'timestamp' => $article->published_at?->toIso8601String() ?? now()->toIso8601String(),
|
||||
]],
|
||||
];
|
||||
}
|
||||
|
||||
private function webhookPayload(Notifier $notifier, Article $article): array
|
||||
{
|
||||
return [
|
||||
'event' => 'article.created',
|
||||
'feed' => $article->feed->displayTitle(),
|
||||
'article' => [
|
||||
'title' => $article->title,
|
||||
'url' => $article->link,
|
||||
'excerpt' => $article->excerpt,
|
||||
'published_at' => $article->published_at?->toIso8601String(),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function discordTestPayload(): array
|
||||
{
|
||||
return ['content' => '🔔 BloomFeed test notification — your Discord webhook is correctly configured.'];
|
||||
}
|
||||
|
||||
private function webhookTestPayload(): array
|
||||
{
|
||||
return ['event' => 'test', 'message' => 'BloomFeed test notification — your webhook is correctly configured.'];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user