<?php declare(strict_types = 1); // {{{ License // This file is part of GNU social - https://www.gnu.org/software/social // // GNU social is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // GNU social is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with GNU social. If not, see <http://www.gnu.org/licenses/>. // }}} namespace Component\Notification; use App\Core\DB; use App\Core\Event; use function App\Core\I18n\_m; use App\Core\Log; use App\Core\Modules\Component; use App\Core\Queue; use App\Core\Router; use App\Entity\Activity; use App\Entity\Actor; use App\Entity\LocalUser; use App\Util\Exception\ServerException; use Component\FreeNetwork\FreeNetwork; use Component\Notification\Controller\Feed; use EventResult; use Exception; use Throwable; class Notification extends Component { public function onAddRoute(Router $m): EventResult { $m->connect('feed_notifications', '/feed/notifications', [Feed::class, 'notifications']); return Event::next; } /** * @throws ServerException */ public function onCreateDefaultFeeds(int $actor_id, LocalUser $user, int &$ordering): EventResult { DB::persist(\App\Entity\Feed::create([ 'actor_id' => $actor_id, 'url' => Router::url($route = 'feed_notifications'), 'route' => $route, 'title' => _m('Notifications'), 'ordering' => $ordering++, ])); return Event::next; } /** * Enqueues a notification for an Actor (such as person or group) which means * it shows up in their home feed and such. * WARNING: It's highly advisable to have flushed any relevant objects before triggering this event. * * $targets should be of the shape: * (int|Actor)[] // Prefer Actor whenever possible * Example of $targets: * [42, $actor_alice, $actor_bob] // Avoid repeating actors or ids * * @param Actor $sender The one responsible for this activity, take care not to include it in targets * @param Activity $activity The activity responsible for the object being given to known to targets * @param non-empty-array<Actor|int> $targets Attentions, Mentions, any other source. Should never be empty, you usually want to register an attention to every $sender->getSubscribers() * @param null|string $reason An optional reason explaining why this notification exists */ public function onNewNotification(Actor $sender, Activity $activity, array $targets, ?string $reason = null): EventResult { // Ensure targets are all actor objects and unique $effective_targets = []; foreach ($targets as $target) { if (\is_int($target)) { $target_id = $target; $target_object = null; } else { $target_id = $target->getId(); $target_object = $target; } if (!\array_key_exists(key: $target_id, array: $effective_targets)) { $target_object ??= Actor::getById($target_id); $effective_targets[$target_id] = $target_object; } } unset($targets); if (Event::handle('NewNotificationStart', [$sender, $activity, $effective_targets, $reason]) === Event::next) { self::notify($sender, $activity, $effective_targets, $reason); } Event::handle('NewNotificationEnd', [$sender, $activity, $effective_targets, $reason]); return Event::next; } /** * @param mixed[] $retry_args */ public function onQueueNotificationLocal(Actor $sender, Activity $activity, Actor $target, ?string $reason, array &$retry_args): EventResult { // TODO: use https://symfony.com/doc/current/notifier.html return Event::stop; } /** * @param Actor[] $targets * @param mixed[] $retry_args */ public function onQueueNotificationRemote(Actor $sender, Activity $activity, array $targets, ?string $reason, array &$retry_args): EventResult { if (FreeNetwork::notify($sender, $activity, $targets, $reason)) { return Event::stop; } else { return Event::next; } } /** * Bring given Activity to Targets' knowledge. * This will flush a Notification to DB. * * @param Actor[] $targets * * @return bool true if successful, false otherwise */ public static function notify(Actor $sender, Activity $activity, array $targets, ?string $reason = null): bool { $remote_targets = []; foreach ($targets as $target) { if ($target->getIsLocal()) { if ($target->hasBlocked($author = $activity->getActor())) { Log::info("Not saving notification to actor {$target->getId()} from sender {$sender->getId()} because receiver blocked author {$author->getId()}."); continue; } if (Event::handle('NewNotificationShould', [$activity, $target]) === Event::next) { if ($sender->getId() === $target->getId() || $activity->getActorId() === $target->getId()) { // The target already knows about this, no need to bother with a notification continue; } } Queue::enqueue( payload: [$sender, $activity, $target, $reason], queue: 'NotificationLocal', priority: true, ); } else { // We have no authority nor responsibility of notifying remote actors of a remote actor's doing if ($sender->getIsLocal()) { $remote_targets[] = $target; } } // XXX: Unideal as in failures the rollback will leave behind a false notification, // but most notifications (all) require flushing the objects first // Should be okay as long as implementations bear this in mind try { DB::wrapInTransaction(fn () => DB::persist(Entity\Notification::create([ 'activity_id' => $activity->getId(), 'target_id' => $target->getId(), 'reason' => $reason, ]))); } catch (Exception|Throwable $e) { // We do our best not to record duplicate notifications, but it's not insane that can happen Log::error('It was attempted to record an invalid notification!', [$e]); } } if ($remote_targets !== []) { Queue::enqueue( payload: [$sender, $activity, $remote_targets, $reason], queue: 'NotificationRemote', priority: false, ); } return true; } }