diff --git a/.gitignore b/.gitignore index e992ad1b2..cb384473a 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,8 @@ web/static/yarn-error\.log # Docker files /docker-compose.yml /*.tar.gz + +# Plugins +/plugins/* +/plugins/**/* +!/plugins/.gitkeep diff --git a/bootstrap/app.php b/bootstrap/app.php index 3f63edefa..a2e44e314 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -58,6 +58,10 @@ ini_set('session.use_strict_mode', 1); $autoloader = require(APP_INCLUDE_VENDOR . '/autoload.php'); $autoloader->setPsr4('Proxy\\', APP_INCLUDE_TEMP . '/proxies'); +// Initialize plugins +$plugins = new \App\Plugins(APP_INCLUDE_ROOT.'/plugins'); +$plugins->registerAutoloaders($autoloader); + // Set up DI container. $di = new \Slim\Container([ 'settings' => [ @@ -69,8 +73,12 @@ $di = new \Slim\Container([ ] ]); +$di[\App\Plugins::class] = $plugins; + // Define services. $settings = require(dirname(__DIR__).'/config/settings.php'); call_user_func(include(dirname(__DIR__).'/config/services.php'), $di, $settings); +$plugins->registerServices($di, $settings); + return $di; diff --git a/composer.json b/composer.json index 89356da46..a77b993ce 100644 --- a/composer.json +++ b/composer.json @@ -41,6 +41,7 @@ "php-http/guzzle6-adapter": "^1.1", "slim/slim": "^3.0", "supervisorphp/supervisor": "^3.0", + "symfony/event-dispatcher": "^4.1", "symfony/finder": "^4.1", "zendframework/zend-config": "^3.1.0", "zendframework/zend-paginator": "^2.7" diff --git a/composer.lock b/composer.lock index 51887cb13..b21acc0cc 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "594ac3f2423a143d5621fb2cffbd11fa", + "content-hash": "1d77d7c553cb7dd8c3f52c09642fcf36", "packages": [ { "name": "azuracast/azuraforms", @@ -3419,6 +3419,69 @@ "homepage": "https://symfony.com", "time": "2018-07-26T11:24:31+00:00" }, + { + "name": "symfony/event-dispatcher", + "version": "v4.1.4", + "source": { + "type": "git", + "url": "https://github.com/symfony/event-dispatcher.git", + "reference": "bfb30c2ad377615a463ebbc875eba64a99f6aa3e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/bfb30c2ad377615a463ebbc875eba64a99f6aa3e", + "reference": "bfb30c2ad377615a463ebbc875eba64a99f6aa3e", + "shasum": "" + }, + "require": { + "php": "^7.1.3" + }, + "conflict": { + "symfony/dependency-injection": "<3.4" + }, + "require-dev": { + "psr/log": "~1.0", + "symfony/config": "~3.4|~4.0", + "symfony/dependency-injection": "~3.4|~4.0", + "symfony/expression-language": "~3.4|~4.0", + "symfony/stopwatch": "~3.4|~4.0" + }, + "suggest": { + "symfony/dependency-injection": "", + "symfony/http-kernel": "" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.1-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Component\\EventDispatcher\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony EventDispatcher Component", + "homepage": "https://symfony.com", + "time": "2018-07-26T09:10:45+00:00" + }, { "name": "symfony/finder", "version": "v4.1.4", @@ -5956,69 +6019,6 @@ "homepage": "https://symfony.com", "time": "2018-07-26T11:00:49+00:00" }, - { - "name": "symfony/event-dispatcher", - "version": "v4.1.4", - "source": { - "type": "git", - "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "bfb30c2ad377615a463ebbc875eba64a99f6aa3e" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/bfb30c2ad377615a463ebbc875eba64a99f6aa3e", - "reference": "bfb30c2ad377615a463ebbc875eba64a99f6aa3e", - "shasum": "" - }, - "require": { - "php": "^7.1.3" - }, - "conflict": { - "symfony/dependency-injection": "<3.4" - }, - "require-dev": { - "psr/log": "~1.0", - "symfony/config": "~3.4|~4.0", - "symfony/dependency-injection": "~3.4|~4.0", - "symfony/expression-language": "~3.4|~4.0", - "symfony/stopwatch": "~3.4|~4.0" - }, - "suggest": { - "symfony/dependency-injection": "", - "symfony/http-kernel": "" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "4.1-dev" - } - }, - "autoload": { - "psr-4": { - "Symfony\\Component\\EventDispatcher\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Symfony EventDispatcher Component", - "homepage": "https://symfony.com", - "time": "2018-07-26T09:10:45+00:00" - }, { "name": "symfony/polyfill-ctype", "version": "v1.9.0", diff --git a/config/events.php b/config/events.php new file mode 100644 index 000000000..4bee5cf7c --- /dev/null +++ b/config/events.php @@ -0,0 +1,14 @@ +addSubscriber(new \App\EventHandler\DefaultRoutes(__DIR__.'/routes.php')); + $dispatcher->addSubscriber(new \App\EventHandler\DefaultView($di)); + $dispatcher->addSubscriber(new \App\EventHandler\DefaultNowPlaying()); + + $dispatcher->addListener(\App\Event\SendWebhooks::NAME, function(\App\Event\SendWebhooks $event) use ($di) { + /** @var \App\Webhook\Dispatcher $webhook_dispatcher */ + $webhook_dispatcher = $di[\App\Webhook\Dispatcher::class]; + + $webhook_dispatcher->dispatch($event); + }); +}; diff --git a/config/services.php b/config/services.php index 28fd2dce0..0beb8b28b 100644 --- a/config/services.php +++ b/config/services.php @@ -233,46 +233,9 @@ return function (\Slim\Container $di, $settings) { $view = new App\View(dirname(__DIR__) . '/resources/templates'); $view->setFileExtension('phtml'); - $view->registerFunction('service', function($service) use ($di) { - return $di->get($service); - }); - - $view->registerFunction('escapeJs', function($string) { - return json_encode($string); - }); - - $view->registerFunction('mailto', function ($address, $link_text = null) { - $address = substr(chunk_split(bin2hex(" $address"), 2, ";&#x"), 3, -3); - $link_text = $link_text ?? $address; - - return '' . $link_text . ''; - }); - - $view->registerFunction('pluralize', function ($word, $num = 0) { - if ((int)$num === 1) { - return $word; - } else { - return \Doctrine\Common\Inflector\Inflector::pluralize($word); - } - }); - - $view->registerFunction('truncate', function ($text, $length = 80) { - return \App\Utilities::truncate_text($text, $length); - }); - - /** @var \App\Session $session */ - $session = $di[\App\Session::class]; - - $view->addData([ - 'app_settings' => $di['app_settings'], - 'router' => $di['router'], - 'request' => $di['request'], - 'assets' => $di[\App\Assets::class], - 'auth' => $di[\App\Auth::class], - 'acl' => $di[\App\Acl::class], - 'flash' => $session->getFlash(), - 'customization' => $di[\App\Customization::class], - ]); + /** @var \Symfony\Component\EventDispatcher\EventDispatcher $dispatcher */ + $dispatcher = $di[\Symfony\Component\EventDispatcher\EventDispatcher::class]; + $dispatcher->dispatch(\App\Event\BuildView::NAME, new \App\Event\BuildView($view)); return $view; }); @@ -337,6 +300,21 @@ return function (\Slim\Container $di, $settings) { ]); }; + $di[\Symfony\Component\EventDispatcher\EventDispatcher::class] = function($di) use ($settings) { + $dispatcher = new \Symfony\Component\EventDispatcher\EventDispatcher(); + + // Register application default events. + call_user_func(include(__DIR__.'/events.php'), $dispatcher, $di, $settings); + + /** @var \App\Plugins $plugins */ + $plugins = $di[\App\Plugins::class]; + + // Register plugin-provided events. + $plugins->registerEvents($dispatcher, $di, $settings); + + return $dispatcher; + }; + // // AzuraCast-specific dependencies // @@ -401,23 +379,9 @@ return function (\Slim\Container $di, $settings) { $app = new \Slim\App($di); - // Get the current user entity object and assign it into the request if it exists. - $app->add(\App\Middleware\GetCurrentUser::class); - - // Inject the application router into the request object. - $app->add(\App\Middleware\EnableRouter::class); - - // Inject the session manager into the request object. - $app->add(\App\Middleware\EnableSession::class); - - // Check HTTPS setting and enforce Content Security Policy accordingly. - $app->add(\App\Middleware\EnforceSecurity::class); - - // Remove trailing slash from all URLs when routing. - $app->add(\App\Middleware\RemoveSlashes::class); - - // Load routes - call_user_func(include(__DIR__.'/routes.php'), $app); + /** @var \Symfony\Component\EventDispatcher\EventDispatcher $dispatcher */ + $dispatcher = $di[\Symfony\Component\EventDispatcher\EventDispatcher::class]; + $dispatcher->dispatch(\App\Event\BuildRoutes::NAME, new \App\Event\BuildRoutes($app)); return $app; }; diff --git a/plugins/.gitkeep b/plugins/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/src/Event/BuildRoutes.php b/src/Event/BuildRoutes.php new file mode 100644 index 000000000..e84404b24 --- /dev/null +++ b/src/Event/BuildRoutes.php @@ -0,0 +1,22 @@ +app = $app; + } + + public function getApp(): App + { + return $this->app; + } +} diff --git a/src/Event/BuildView.php b/src/Event/BuildView.php new file mode 100644 index 000000000..d63727caa --- /dev/null +++ b/src/Event/BuildView.php @@ -0,0 +1,22 @@ +view = $view; + } + + public function getView(): View + { + return $this->view; + } +} diff --git a/src/Event/GenerateRawNowPlaying.php b/src/Event/GenerateRawNowPlaying.php new file mode 100644 index 000000000..93c06ee80 --- /dev/null +++ b/src/Event/GenerateRawNowPlaying.php @@ -0,0 +1,82 @@ +station = $station; + $this->frontend = $frontend; + $this->remotes = $remotes; + $this->payload = $payload; + $this->include_clients = $include_clients; + } + + public function getStation(): Station + { + return $this->station; + } + + public function getFrontend(): FrontendAbstract + { + return $this->frontend; + } + + /** + * @return RemoteAbstract[] + */ + public function getRemotes(): array + { + return $this->remotes; + } + + public function includeClients(): bool + { + return $this->include_clients; + } + + public function getPayload(): ?string + { + return $this->payload; + } + + public function getRawResponse(): array + { + return $this->np_raw; + } + + public function setRawResponse(array $np): void + { + $this->np_raw = $np; + } +} diff --git a/src/Event/SendWebhooks.php b/src/Event/SendWebhooks.php new file mode 100644 index 000000000..69bfcbaa0 --- /dev/null +++ b/src/Event/SendWebhooks.php @@ -0,0 +1,93 @@ +station = $station; + $this->np = $np; + $this->is_standalone = $is_standalone; + + $to_trigger = ['all']; + + if ($np_old instanceof NowPlaying) { + if ($np_old->now_playing->song->id !== $np->now_playing->song->id) { + $to_trigger[] = 'song_changed'; + } + + if ($np_old->listeners->current > $np->listeners->current) { + $to_trigger[] = 'listener_lost'; + } elseif ($np_old->listeners->current < $np->listeners->current) { + $to_trigger[] = 'listener_gained'; + } + + if ($np_old->live->is_live === false && $np->live->is_live === true) { + $to_trigger[] = 'live_connect'; + } elseif ($np_old->live->is_live === true && $np->live->is_live === false) { + $to_trigger[] = 'live_disconnect'; + } + } + + $this->triggers = $to_trigger; + } + + /** + * @return Station + */ + public function getStation(): Station + { + return $this->station; + } + + /** + * @return NowPlaying + */ + public function getNowPlaying(): NowPlaying + { + return $this->np; + } + + /** + * @return array + */ + public function getTriggers(): array + { + return $this->triggers; + } + + /** + * @param $trigger_name + * @return bool + */ + public function hasTrigger($trigger_name): bool + { + return in_array($trigger_name, $this->triggers); + } + + /** + * @return bool + */ + public function isStandalone(): bool + { + return $this->is_standalone; + } +} diff --git a/src/EventHandler/DefaultNowPlaying.php b/src/EventHandler/DefaultNowPlaying.php new file mode 100644 index 000000000..ffeb74065 --- /dev/null +++ b/src/EventHandler/DefaultNowPlaying.php @@ -0,0 +1,55 @@ + [ + ['loadRawFromFrontend', 10], + ['addToRawFromRemotes', 0], + ['cleanUpRawOutput', -10], + ], + ]; + } + + public function loadRawFromFrontend(GenerateRawNowPlaying $event) + { + $np_raw = $event->getFrontend()->getNowPlaying($event->getPayload(), $event->includeClients()); + + $event->setRawResponse($np_raw); + } + + public function addToRawFromRemotes(GenerateRawNowPlaying $event) + { + $np_raw = $event->getRawResponse(); + + // Loop through all remotes and update NP data accordingly. + foreach($event->getRemotes() as $remote_adapter) { + $remote_adapter->updateNowPlaying($np_raw, $event->includeClients()); + } + + $event->setRawResponse($np_raw); + } + + public function cleanUpRawOutput(GenerateRawNowPlaying $event) + { + $np_raw = $event->getRawResponse(); + + array_walk($np_raw['current_song'], function(&$value) { + $value = htmlspecialchars_decode($value); + $value = trim($value); + }); + + $event->setRawResponse($np_raw); + } + +} diff --git a/src/EventHandler/DefaultRoutes.php b/src/EventHandler/DefaultRoutes.php new file mode 100644 index 000000000..ad6c014d3 --- /dev/null +++ b/src/EventHandler/DefaultRoutes.php @@ -0,0 +1,50 @@ + [ + ['addDefaultMiddleware', 1], + ['addDefaultRoutes', 0], + ], + ]; + } + + protected $routes_path; + + public function __construct($routes_path) + { + $this->routes_path = $routes_path; + } + + public function addDefaultMiddleware(BuildRoutes $event) + { + $app = $event->getApp(); + + // Get the current user entity object and assign it into the request if it exists. + $app->add(\App\Middleware\GetCurrentUser::class); + + // Inject the application router into the request object. + $app->add(\App\Middleware\EnableRouter::class); + + // Inject the session manager into the request object. + $app->add(\App\Middleware\EnableSession::class); + + // Check HTTPS setting and enforce Content Security Policy accordingly. + $app->add(\App\Middleware\EnforceSecurity::class); + + // Remove trailing slash from all URLs when routing. + $app->add(\App\Middleware\RemoveSlashes::class); + } + + public function addDefaultRoutes(BuildRoutes $event) + { + call_user_func(include($this->routes_path), $event->getApp()); + } +} diff --git a/src/EventHandler/DefaultView.php b/src/EventHandler/DefaultView.php new file mode 100644 index 000000000..18d4c975b --- /dev/null +++ b/src/EventHandler/DefaultView.php @@ -0,0 +1,76 @@ + [ + ['addViewFunctions', 1], + ['addViewData', 0], + ], + ]; + } + + /** @var Container */ + protected $di; + + public function __construct(Container $di) + { + $this->di = $di; + } + + public function addViewFunctions(BuildView $event) + { + $view = $event->getView(); + + $view->registerFunction('service', function($service) { + return $this->di->get($service); + }); + + $view->registerFunction('escapeJs', function($string) { + return json_encode($string); + }); + + $view->registerFunction('mailto', function ($address, $link_text = null) { + $address = substr(chunk_split(bin2hex(" $address"), 2, ";&#x"), 3, -3); + $link_text = $link_text ?? $address; + + return '' . $link_text . ''; + }); + + $view->registerFunction('pluralize', function ($word, $num = 0) { + if ((int)$num === 1) { + return $word; + } else { + return \Doctrine\Common\Inflector\Inflector::pluralize($word); + } + }); + + $view->registerFunction('truncate', function ($text, $length = 80) { + return \App\Utilities::truncate_text($text, $length); + }); + } + + public function addViewData(BuildView $event) + { + /** @var \App\Session $session */ + $session = $this->di[\App\Session::class]; + + $event->getView()->addData([ + 'app_settings' => $this->di['app_settings'], + 'router' => $this->di['router'], + 'request' => $this->di['request'], + 'assets' => $this->di[\App\Assets::class], + 'auth' => $this->di[\App\Auth::class], + 'acl' => $this->di[\App\Acl::class], + 'flash' => $session->getFlash(), + 'customization' => $this->di[\App\Customization::class], + ]); + } +} diff --git a/src/EventHandler/DefaultWebhooks.php b/src/EventHandler/DefaultWebhooks.php new file mode 100644 index 000000000..0dbc4c5de --- /dev/null +++ b/src/EventHandler/DefaultWebhooks.php @@ -0,0 +1,137 @@ +logger = $logger; + $this->connectors = $connectors; + } + + public static function getSubscribedEvents() + { + if (APP_TESTING_MODE) { + return []; + } + + return [ + SendWebhooks::NAME => [ + ['dispatchWebhooks', 0], + ], + ]; + } + + public function dispatchWebhooks(SendWebhooks $event) + { + // Compile list of connectors for the station. Always dispatch to the local websocket receiver. + $connectors = []; + + if ($event->isStandalone()) { + $connectors[] = [ + 'type' => 'local', + 'triggers' => [], + 'config' => [], + ]; + } + + // Assemble list of webhooks for the station + $station_webhooks = $event->getStation()->getWebhooks(); + + if ($station_webhooks->count() > 0) { + foreach($station_webhooks as $webhook) { + /** @var Entity\StationWebhook $webhook */ + if ($webhook->isEnabled()) { + $connectors[] = [ + 'type' => $webhook->getType(), + 'triggers' => $webhook->getTriggers() ?: [], + 'config' => $webhook->getConfig() ?: [], + ]; + } + } + } + + $this->logger->debug('Triggering events: '.implode(', ', $event->getTriggers())); + + // Trigger all appropriate webhooks. + foreach($connectors as $connector) { + if (!$this->connectors->has($connector['type'])) { + $this->logger->error(sprintf('Webhook connector "%s" does not exist; skipping.', $connector['type'])); + continue; + } + + /** @var Connector\ConnectorInterface $connector_obj */ + $connector_obj = $this->connectors->get($connector['type']); + + if ($connector_obj->shouldDispatch($event, (array)$connector['triggers'])) { + $this->logger->debug(sprintf('Dispatching connector "%s".', $connector['type'])); + + $connector_obj->dispatch($event, (array)$connector['config']); + } + } + } + + /** + * Send a "test" dispatch of the web hook, regardless of whether it is currently enabled, and + * return any logging information this yields. + * + * @param Entity\Station $station + * @param Entity\StationWebhook $webhook + * @return TestHandler + * @throws Exception + */ + public function testDispatch(Entity\Station $station, Entity\StationWebhook $webhook) + { + $webhook_type = $webhook->getType(); + $webhook_config = $webhook->getConfig(); + + if (!$this->connectors->has($webhook_type)) { + throw new Exception(sprintf('Webhook connector "%s" does not exist; skipping.', $webhook_type)); + } + + $handler = new TestHandler(Logger::DEBUG, false); + $this->logger->pushHandler($handler); + + /** @var Connector\ConnectorInterface $connector_obj */ + $connector_obj = $this->connectors->get($webhook_type); + + $np = $station->getNowplaying(); + + $event = new SendWebhooks($station, $np); + $connector_obj->dispatch($event, $webhook_config); + + $this->logger->popHandler(); + + return $handler; + } + + /** + * Directly access a webhook connector of the specified type. + * + * @param $type + * @return Connector\ConnectorInterface + */ + public function getConnector($type): Connector\ConnectorInterface + { + if ($this->connectors->has($type)) { + return $this->connectors->get($type); + } + + throw new \InvalidArgumentException('Invalid web hook connector type specified.'); + } +} diff --git a/src/Middleware/EnableView.php b/src/Middleware/EnableView.php index ece0874ea..6400a3972 100644 --- a/src/Middleware/EnableView.php +++ b/src/Middleware/EnableView.php @@ -28,7 +28,6 @@ class EnableView { $this->view->addData([ 'request' => $request, - 'user' => $request->getAttribute(Request::ATTRIBUTE_USER), ]); $request = $request->withAttribute(Request::ATTRIBUTE_VIEW, $this->view); diff --git a/src/Middleware/GetCurrentUser.php b/src/Middleware/GetCurrentUser.php index 618869ce0..762b586d4 100644 --- a/src/Middleware/GetCurrentUser.php +++ b/src/Middleware/GetCurrentUser.php @@ -3,9 +3,11 @@ namespace App\Middleware; use App\Auth; use App\Customization; +use App\Event\BuildView; use App\Http\Request; use App\Http\Response; use App\Entity; +use Symfony\Component\EventDispatcher\EventDispatcher; /** * Get the current user entity object and assign it into the request if it exists. @@ -18,10 +20,14 @@ class GetCurrentUser /** @var Customization */ protected $customization; - public function __construct(Auth $auth, Customization $customization) + /** @var EventDispatcher */ + protected $dispatcher; + + public function __construct(Auth $auth, Customization $customization, EventDispatcher $dispatcher) { $this->auth = $auth; $this->customization = $customization; + $this->dispatcher = $dispatcher; } /** @@ -39,6 +45,12 @@ class GetCurrentUser $this->customization->setUser($user); $request = $this->customization->init($request); + $this->dispatcher->addListener(BuildView::NAME, function(BuildView $event) use ($user) { + $event->getView()->addData([ + 'user' => $user, + ]); + }); + $request = $request ->withAttribute(Request::ATTRIBUTE_USER, $user) ->withAttribute('is_logged_in', ($user instanceof Entity\User)); diff --git a/src/Plugins.php b/src/Plugins.php new file mode 100644 index 000000000..33039f942 --- /dev/null +++ b/src/Plugins.php @@ -0,0 +1,91 @@ +loadDirectory($base_dir); + } + + public function loadDirectory($dir): void + { + $plugins = (new Finder()) + ->ignoreUnreadableDirs() + ->directories() + ->in($dir); + + foreach($plugins as $plugin_dir) { + /** @var SplFileInfo $plugin_dir */ + $plugin_prefix = $plugin_dir->getRelativePathname(); + $plugin_namespace = 'Plugin\\'.\Doctrine\Common\Inflector\Inflector::classify($plugin_prefix).'\\'; + + $this->plugins[$plugin_prefix] = [ + 'namespace' => $plugin_namespace, + 'path' => $plugin_dir->getPathname(), + ]; + } + } + + /** + * Add plugin namespace classes (and any Composer dependencies) to the global include list. + * + * @param ClassLoader $autoload + */ + public function registerAutoloaders(ClassLoader $autoload): void + { + foreach($this->plugins as $plugin) { + $plugin_path = $plugin['path']; + + if (file_exists($plugin_path.'/vendor/autoload.php')) { + require($plugin_path.'/vendor/autoload.php'); + } + + $autoload->addPsr4($plugin['namespace'], $plugin_path.'/src'); + } + } + + /** + * Register or override any services contained in the global Dependency Injection container. + * + * @param Container $di + * @param array $settings + */ + public function registerServices(Container $di, array $settings): void + { + foreach($this->plugins as $plugin) { + $plugin_path = $plugin['path']; + + if (file_exists($plugin_path . '/services.php')) { + call_user_func(include($plugin_path . '/services.php'), $di, $settings); + } + } + } + + /** + * Register custom events that the plugin overrides with the Event Dispatcher. + * + * @param EventDispatcher $dispatcher + * @param Container $di + * @param array $settings + */ + public function registerEvents(EventDispatcher $dispatcher, Container $di, array $settings): void + { + foreach($this->plugins as $plugin) { + $plugin_path = $plugin['path']; + + if (file_exists($plugin_path . '/events.php')) { + call_user_func(include($plugin_path . '/events.php'), $dispatcher, $di, $settings); + } + } + } +} diff --git a/src/Provider/MiddlewareProvider.php b/src/Provider/MiddlewareProvider.php index c19a386e8..e1c8cebbe 100644 --- a/src/Provider/MiddlewareProvider.php +++ b/src/Provider/MiddlewareProvider.php @@ -33,7 +33,8 @@ class MiddlewareProvider implements ServiceProviderInterface $di[Middleware\GetCurrentUser::class] = function($di) { return new Middleware\GetCurrentUser( $di[App\Auth::class], - $di[App\Customization::class] + $di[App\Customization::class], + $di[\Symfony\Component\EventDispatcher\EventDispatcher::class] ); }; diff --git a/src/Provider/SyncProvider.php b/src/Provider/SyncProvider.php index 61e94f884..420cb1f30 100644 --- a/src/Provider/SyncProvider.php +++ b/src/Provider/SyncProvider.php @@ -57,8 +57,8 @@ class SyncProvider implements ServiceProviderInterface $di[\App\Radio\AutoDJ::class], $di[\App\Cache::class], $di[\InfluxDB\Database::class], - $di[\App\Webhook\Dispatcher::class], $di[\Doctrine\ORM\EntityManager::class], + $di[\Symfony\Component\EventDispatcher\EventDispatcher::class], $di[\Monolog\Logger::class] ); }; diff --git a/src/Sync/Task/NowPlaying.php b/src/Sync/Task/NowPlaying.php index b36cec4fb..cca4f5761 100644 --- a/src/Sync/Task/NowPlaying.php +++ b/src/Sync/Task/NowPlaying.php @@ -2,15 +2,17 @@ namespace App\Sync\Task; use App\Cache; +use App\Event\GenerateRawNowPlaying; +use App\Event\SendWebhooks; use App\Radio\AutoDJ; use App\ApiUtilities; use App\Radio\Adapters; -use App\Radio\Frontend\FrontendAbstract; use App\Webhook\Dispatcher; use Doctrine\ORM\EntityManager; use InfluxDB\Database; use App\Entity; use Monolog\Logger; +use Symfony\Component\EventDispatcher\EventDispatcher; class NowPlaying extends TaskAbstract { @@ -32,8 +34,8 @@ class NowPlaying extends TaskAbstract /** @var Logger */ protected $logger; - /** @var Dispatcher */ - protected $webhook_dispatcher; + /** @var EventDispatcher */ + protected $event_dispatcher; /** @var ApiUtilities */ protected $api_utils; @@ -54,14 +56,15 @@ class NowPlaying extends TaskAbstract protected $analytics_level; /** - * @param EntityManager $em - * @param Database $influx - * @param Cache $cache * @param Adapters $adapters - * @param Dispatcher $webhook_dispatcher * @param ApiUtilities $api_utils * @param AutoDJ $autodj + * @param Cache $cache + * @param Database $influx + * @param EntityManager $em + * @param EventDispatcher $event_dispatcher * @param Logger $logger + * * @see \App\Provider\SyncProvider */ public function __construct( @@ -70,8 +73,8 @@ class NowPlaying extends TaskAbstract AutoDJ $autodj, Cache $cache, Database $influx, - Dispatcher $webhook_dispatcher, EntityManager $em, + EventDispatcher $event_dispatcher, Logger $logger) { $this->adapters = $adapters; @@ -79,9 +82,9 @@ class NowPlaying extends TaskAbstract $this->autodj = $autodj; $this->cache = $cache; $this->em = $em; + $this->event_dispatcher = $event_dispatcher; $this->influx = $influx; $this->logger = $logger; - $this->webhook_dispatcher = $webhook_dispatcher; $this->history_repo = $this->em->getRepository(Entity\SongHistory::class); $this->song_repo = $this->em->getRepository(Entity\Song::class); @@ -198,27 +201,13 @@ class NowPlaying extends TaskAbstract /** @var Entity\Api\NowPlaying|null $np_old */ $np_old = $station->getNowplaying(); + // Build the new "raw" NowPlaying data. + $event = new GenerateRawNowPlaying($station, $frontend_adapter, $remote_adapters, $payload, $include_clients); + $this->event_dispatcher->dispatch(GenerateRawNowPlaying::NAME, $event); + $np_raw = $event->getRawResponse(); + $np = new Entity\Api\NowPlaying; $np->station = $station->api($frontend_adapter, $remote_adapters); - - // Build the new "raw" NowPlaying data from the adapters. - if (APP_TESTING_MODE) { - $np_raw = \NowPlaying\Adapter\AdapterAbstract::NOWPLAYING_EMPTY; - } else { - $np_raw = $frontend_adapter->getNowPlaying($payload, $include_clients); - - // Loop through all remotes and update NP data accordingly. - foreach($remote_adapters as $remote_adapter) { - $remote_adapter->updateNowPlaying($np_raw, $include_clients); - } - - array_walk($np_raw['current_song'], function(&$value) { - $value = htmlspecialchars_decode($value); - $value = trim($value); - }); - } - - // Start to convert the "raw" NowPlaying data into the proper API entities. $np->listeners = new Entity\Api\NowPlayingListeners($np_raw['listeners']); if (empty($np_raw['current_song']['text'])) { @@ -295,8 +284,9 @@ class NowPlaying extends TaskAbstract $this->em->persist($station); $this->em->flush(); - $np_old = ($np_old instanceof Entity\Api\NowPlaying) ? $np_old : $np; - $this->webhook_dispatcher->dispatch($station, $np_old, $np, ($payload !== null)); + // Trigger the dispatching of webhooks. + $webhook_event = new SendWebhooks($station, $np, $np_old, ($payload !== null)); + $this->event_dispatcher->dispatch(SendWebhooks::NAME, $webhook_event); $this->logger->popProcessor(); diff --git a/src/Webhook/Connector/AbstractConnector.php b/src/Webhook/Connector/AbstractConnector.php index 419881d24..33f850dd8 100644 --- a/src/Webhook/Connector/AbstractConnector.php +++ b/src/Webhook/Connector/AbstractConnector.php @@ -2,6 +2,7 @@ namespace App\Webhook\Connector; use App\Entity; +use App\Event\SendWebhooks; use App\Utilities; use GuzzleHttp\Client; use Monolog\Logger; @@ -20,14 +21,14 @@ abstract class AbstractConnector implements ConnectorInterface $this->http_client = $http_client; } - public function shouldDispatch(array $current_events, array $triggers): bool + public function shouldDispatch(SendWebhooks $event, array $triggers): bool { if (empty($triggers)) { return true; } foreach($triggers as $trigger) { - if (in_array($trigger, $current_events)) { + if ($event->hasTrigger($trigger)) { return true; } } diff --git a/src/Webhook/Connector/ConnectorInterface.php b/src/Webhook/Connector/ConnectorInterface.php index 18d1aad8f..b6adab5df 100644 --- a/src/Webhook/Connector/ConnectorInterface.php +++ b/src/Webhook/Connector/ConnectorInterface.php @@ -1,7 +1,7 @@ _getValidUrl($config['webhook_url'] ?? ''); @@ -84,7 +80,7 @@ class Discord extends AbstractConnector 'footer' => $config['footer'] ?? '', ]; - $vars = $this->_replaceVariables($raw_vars, $np); + $vars = $this->_replaceVariables($raw_vars, $event->getNowPlaying()); // Compose webhook $embed = [ diff --git a/src/Webhook/Connector/Generic.php b/src/Webhook/Connector/Generic.php index 4ccccf841..fe2ad78ef 100644 --- a/src/Webhook/Connector/Generic.php +++ b/src/Webhook/Connector/Generic.php @@ -2,17 +2,13 @@ namespace App\Webhook\Connector; use App\Entity; +use App\Event\SendWebhooks; use GuzzleHttp\Exception\TransferException; use Monolog\Logger; class Generic extends AbstractConnector { - /** - * @param Entity\Station $station - * @param Entity\Api\NowPlaying $np - * @param array $config - */ - public function dispatch(Entity\Station $station, Entity\Api\NowPlaying $np, array $config): void + public function dispatch(SendWebhooks $event, array $config): void { $webhook_url = $this->_getValidUrl($config['webhook_url'] ?? ''); @@ -26,7 +22,7 @@ class Generic extends AbstractConnector 'headers' => [ 'Content-Type' => 'application/json', ], - 'json' => $np, + 'json' => $event->getNowPlaying(), ]; if (!empty($config['basic_auth_username']) && !empty($config['basic_auth_password'])) { diff --git a/src/Webhook/Connector/Local.php b/src/Webhook/Connector/Local.php index 59a0e96d1..54cf079de 100644 --- a/src/Webhook/Connector/Local.php +++ b/src/Webhook/Connector/Local.php @@ -3,6 +3,7 @@ namespace App\Webhook\Connector; use App\Cache; use App\Entity; +use App\Event\SendWebhooks; use GuzzleHttp\Client; use InfluxDB\Database; use Monolog\Logger; @@ -27,33 +28,21 @@ class Local extends AbstractConnector $this->settings_repo = $settings_repo; } - /** - * @param array $current_events - * @param array|null $triggers - * @return bool - */ - public function shouldDispatch(array $current_events, array $triggers): bool + public function shouldDispatch(SendWebhooks $event, array $triggers): bool { return true; } - /** - * @param Entity\Station $station - * @param Entity\Api\NowPlaying $np - * @param array $config - * @throws Database\Exception - * @throws \InfluxDB\Exception - */ - public function dispatch(Entity\Station $station, Entity\Api\NowPlaying $np, array $config): void + public function dispatch(SendWebhooks $event, array $config): void { $this->logger->debug('Writing entry to InfluxDB...'); // Post statistics to InfluxDB. $influx_point = new \InfluxDB\Point( - 'station.' . $station->getId() . '.listeners', + 'station.' . $event->getStation()->getId() . '.listeners', (int)$np->listeners->current, [], - ['station' => $station->getId()], + ['station' => $event->getStation()->getId()], time() ); @@ -67,7 +56,7 @@ class Local extends AbstractConnector if ($np_full) { foreach($np_full as &$np_row) { /** @var Entity\Api\NowPlaying $np_row */ - if ($np_row->station->id === $station->getId()) { + if ($np_row->station->id === $event->getStation()->getId()) { $np_row = $np; } diff --git a/src/Webhook/Connector/Telegram.php b/src/Webhook/Connector/Telegram.php index 5a534bf9f..eece895f8 100644 --- a/src/Webhook/Connector/Telegram.php +++ b/src/Webhook/Connector/Telegram.php @@ -1,7 +1,7 @@ _replaceVariables([ 'text' => $config['text'], - ], $np); + ], $event->getNowPlaying()); try { $api_url = (!empty($config['api'])) ? rtrim($config['api'], '/') : 'https://api.telegram.org'; diff --git a/src/Webhook/Connector/TuneIn.php b/src/Webhook/Connector/TuneIn.php index 088d8d166..a229c2b3b 100644 --- a/src/Webhook/Connector/TuneIn.php +++ b/src/Webhook/Connector/TuneIn.php @@ -2,27 +2,18 @@ namespace App\Webhook\Connector; use App\Entity; +use App\Event\SendWebhooks; use GuzzleHttp\Exception\TransferException; use Monolog\Logger; class TuneIn extends AbstractConnector { - /** - * @param array $current_events - * @param array|null $triggers - * @return bool - */ - public function shouldDispatch(array $current_events, array $triggers): bool + public function shouldDispatch(SendWebhooks $event, array $triggers): bool { - return in_array('song_changed', $current_events); + return $event->hasTrigger('song_changed'); } - /** - * @param Entity\Station $station - * @param Entity\Api\NowPlaying $np - * @param array $config - */ - public function dispatch(Entity\Station $station, Entity\Api\NowPlaying $np, array $config): void + public function dispatch(SendWebhooks $event, array $config): void { if (empty($config['partner_id']) || empty($config['partner_key']) || empty($config['station_id'])) { $this->logger->error('Webhook '.$this->_getName().' is missing necessary configuration. Skipping...'); @@ -32,6 +23,8 @@ class TuneIn extends AbstractConnector $this->logger->debug('Dispatching TuneIn AIR API call...'); try { + $np = $event->getNowPlaying(); + $response = $this->http_client->get('http://air.radiotime.com/Playing.ashx', [ 'query' => [ 'partnerId' => $config['partner_id'], diff --git a/src/Webhook/Connector/Twitter.php b/src/Webhook/Connector/Twitter.php index af3ac3006..b1adae94d 100644 --- a/src/Webhook/Connector/Twitter.php +++ b/src/Webhook/Connector/Twitter.php @@ -2,18 +2,14 @@ namespace App\Webhook\Connector; use App\Entity; +use App\Event\SendWebhooks; use GuzzleHttp\Exception\TransferException; use GuzzleHttp\HandlerStack; use Monolog\Logger; class Twitter extends AbstractConnector { - /** - * @param Entity\Station $station - * @param Entity\Api\NowPlaying $np - * @param array $config - */ - public function dispatch(Entity\Station $station, Entity\Api\NowPlaying $np, array $config): void + public function dispatch(SendWebhooks $event, array $config): void { if (empty($config['consumer_key']) || empty($config['consumer_secret']) @@ -40,7 +36,7 @@ class Twitter extends AbstractConnector 'message' => $config['message'] ?? '', ]; - $vars = $this->_replaceVariables($raw_vars, $np); + $vars = $this->_replaceVariables($raw_vars, $event->getNowPlaying()); // Dispatch webhook $this->logger->debug('Posting to Twitter...'); diff --git a/src/Webhook/Dispatcher.php b/src/Webhook/Dispatcher.php index df51d6831..7f8a55c3b 100644 --- a/src/Webhook/Dispatcher.php +++ b/src/Webhook/Dispatcher.php @@ -3,6 +3,7 @@ namespace App\Webhook; use App\Config; use App\Entity; +use App\Event\SendWebhooks; use App\Exception; use App\Provider\WebhookProvider; use Monolog\Handler\TestHandler; @@ -31,12 +32,9 @@ class Dispatcher /** * Determine which webhooks to dispatch for a given change in Now Playing data, and dispatch them. * - * @param Entity\Station $station - * @param Entity\Api\NowPlaying $np_old - * @param Entity\Api\NowPlaying $np_new - * @param boolean $is_standalone + * @param SendWebhooks $event */ - public function dispatch(Entity\Station $station, Entity\Api\NowPlaying $np_old, Entity\Api\NowPlaying $np_new, $is_standalone = true): void + public function dispatch(SendWebhooks $event): void { if (APP_TESTING_MODE) { $this->logger->info('In testing mode; no webhooks dispatched.'); @@ -46,7 +44,7 @@ class Dispatcher // Compile list of connectors for the station. Always dispatch to the local websocket receiver. $connectors = []; - if ($is_standalone) { + if ($event->isStandalone()) { $connectors[] = [ 'type' => 'local', 'triggers' => [], @@ -55,7 +53,7 @@ class Dispatcher } // Assemble list of webhooks for the station - $station_webhooks = $station->getWebhooks(); + $station_webhooks = $event->getStation()->getWebhooks(); if ($station_webhooks->count() > 0) { foreach($station_webhooks as $webhook) { @@ -70,26 +68,7 @@ class Dispatcher } } - // Determine which events should be triggered as a result of this change. - $to_trigger = ['all']; - - if ($np_old->now_playing->song->id !== $np_new->now_playing->song->id) { - $to_trigger[] = 'song_changed'; - } - - if ($np_old->listeners->current > $np_new->listeners->current) { - $to_trigger[] = 'listener_lost'; - } elseif ($np_old->listeners->current < $np_new->listeners->current) { - $to_trigger[] = 'listener_gained'; - } - - if ($np_old->live->is_live === false && $np_new->live->is_live === true) { - $to_trigger[] = 'live_connect'; - } elseif ($np_old->live->is_live === true && $np_new->live->is_live === false) { - $to_trigger[] = 'live_disconnect'; - } - - $this->logger->debug('Triggering events: '.implode(', ', $to_trigger)); + $this->logger->debug('Triggering events: '.implode(', ', $event->getTriggers())); // Trigger all appropriate webhooks. foreach($connectors as $connector) { @@ -101,10 +80,10 @@ class Dispatcher /** @var Connector\ConnectorInterface $connector_obj */ $connector_obj = $this->connectors->get($connector['type']); - if ($connector_obj->shouldDispatch($to_trigger, (array)$connector['triggers'])) { + if ($connector_obj->shouldDispatch($event, (array)$connector['triggers'])) { $this->logger->debug(sprintf('Dispatching connector "%s".', $connector['type'])); - $connector_obj->dispatch($station, $np_new, (array)$connector['config']); + $connector_obj->dispatch($event, (array)$connector['config']); } } } @@ -135,7 +114,8 @@ class Dispatcher $np = $station->getNowplaying(); - $connector_obj->dispatch($station, $np, $webhook_config); + $event = new SendWebhooks($station, $np); + $connector_obj->dispatch($event, $webhook_config); $this->logger->popHandler();