Add Mastodon webhook; refactor Webhook dispatching.

This commit is contained in:
Buster Neece 2022-11-17 07:12:27 -06:00
parent 493423c6bf
commit 33a1c84b5a
No known key found for this signature in database
GPG Key ID: F1D2E64A0005E80E
17 changed files with 502 additions and 296 deletions

View File

@ -5,6 +5,8 @@ release channel, you can take advantage of these new features and fixes.
## New Features/Changes
- **Mastodon Posting Support**: Publish to Mastodon via a Web Hook, the same way you do with Twitter!
- **Cover Art Files Support**: Many users keep the cover art for their media alongside the media in a separate image
file. AzuraCast now detects image files in the same folder as your media and uses it as the default album art for that
media. Because cover art files are often named a variety of things, we currently will use _any_ image file that exists
@ -20,6 +22,8 @@ release channel, you can take advantage of these new features and fixes.
## Bug Fixes
- Fixed an issue where listener connection times over a day didn't properly show up.
- Fixed several issues contributing to slow load times on media manager pages.
- Fixed a bug where if a station only had "Allowed IPs", it wouldn't be enforced.

View File

@ -61,6 +61,12 @@ return [
'description' => __('Automatically send a tweet.'),
'triggers' => $allTriggers,
],
Connector\Mastodon::NAME => [
'class' => Connector\Mastodon::class,
'name' => __('Mastodon Post'),
'description' => __('Automatically publish to a Mastodon instance.'),
'triggers' => [],
],
Connector\GoogleAnalytics::NAME => [
'class' => Connector\GoogleAnalytics::class,
'name' => __('Google Analytics Integration'),

View File

@ -24,14 +24,15 @@ import BaseEditModal from '~/components/Common/BaseEditModal';
import TypeSelect from "./Form/TypeSelect";
import BasicInfo from "./Form/BasicInfo";
import _ from "lodash";
import Generic from "~/components/Stations/Webhooks/Form/Generic";
import Email from "~/components/Stations/Webhooks/Form/Email";
import Tunein from "~/components/Stations/Webhooks/Form/Tunein";
import Discord from "~/components/Stations/Webhooks/Form/Discord";
import Telegram from "~/components/Stations/Webhooks/Form/Telegram";
import Twitter from "~/components/Stations/Webhooks/Form/Twitter";
import GoogleAnalytics from "~/components/Stations/Webhooks/Form/GoogleAnalytics";
import MatomoAnalytics from "~/components/Stations/Webhooks/Form/MatomoAnalytics";
import Generic from "./Form/Generic";
import Email from "./Form/Email";
import Tunein from "./Form/Tunein";
import Discord from "./Form/Discord";
import Telegram from "./Form/Telegram";
import Twitter from "./Form/Twitter";
import GoogleAnalytics from "./Form/GoogleAnalytics";
import MatomoAnalytics from "./Form/MatomoAnalytics";
import Mastodon from "./Form/Mastodon";
export default {
name: 'EditModal',
@ -193,6 +194,23 @@ export default {
message: this.langTwitterDefaultMessage
}
},
'mastodon': {
component: Mastodon,
validations: {
instance_url: {required},
access_token: {required},
rate_limit: {},
message: {required},
visibility: {required}
},
defaultConfig: {
instance_url: '',
access_token: '',
rate_limit: 0,
message: this.langTwitterDefaultMessage,
visibility: 'public'
}
},
'google_analytics': {
component: GoogleAnalytics,
validations: {

View File

@ -0,0 +1,167 @@
<template>
<div>
<b-form-group>
<template #label>
<translate key="lang_mastodon_hdr">Mastodon Account Details</translate>
</template>
<p class="card-text">
<translate key="lang_mastodon_instructions_1">Steps for configuring a Mastodon application:</translate>
</p>
<ul>
<li>
<translate key="lang_mastodon_instructions_1">Visit your Mastodon instance.</translate>
</li>
<li>
<translate key="lang_mastodon_instructions_2">Click the "Preferences" link, then "Development" on the left side menu.</translate>
</li>
<li>
<translate key="lang_mastodon_instructions_3">Click "New Application"</translate>
</li>
<li>
<translate key="lang_mastodon_instructions_4">Enter "AzuraCast" as the application name. You can leave the URL fields unchanged. For "Scopes", only "write:media" and "write:statuses" are required.</translate>
</li>
</ul>
<p class="card-text">
<translate key="lang_twitter_instructions_5">Once these steps are completed, enter the "Access Token" from the application's page into the field below.</translate>
</p>
</b-form-group>
<b-form-group>
<b-form-row>
<b-wrapped-form-group class="col-md-6" id="form_config_instance_url" :field="form.config.instance_url">
<template #label="{lang}">
<translate :key="lang">Mastodon Instance URL</translate>
</template>
<template #description="{lang}">
<translate
:key="lang">If your Mastodon username is "@test@example.com", enter "example.com".</translate>
</template>
</b-wrapped-form-group>
<b-wrapped-form-group class="col-md-6" id="form_config_access_token"
:field="form.config.access_token">
<template #label="{lang}">
<translate :key="lang">Access Token</translate>
</template>
</b-wrapped-form-group>
<b-wrapped-form-group class="col-md-12" id="form_config_rate_limit" :field="form.config.rate_limit">
<template #label="{lang}">
<translate :key="lang">Only Post Once Every...</translate>
</template>
<template #default="props">
<b-form-radio-group stacked :id="props.id" :options="rateLimitOptions"
v-model="props.field.$model">
</b-form-radio-group>
</template>
</b-wrapped-form-group>
</b-form-row>
</b-form-group>
<common-formatting-info></common-formatting-info>
<b-form-group>
<b-form-row>
<b-wrapped-form-group class="col-md-12" id="form_config_message" :field="form.config.message"
input-type="textarea">
<template #label="{lang}">
<translate :key="lang">Message Body</translate>
</template>
</b-wrapped-form-group>
<b-wrapped-form-group class="col-md-12" id="form_config_visibility" :field="form.config.visibility">
<template #label="{lang}">
<translate :key="lang">Message Visibility</translate>
</template>
<template #default="props">
<b-form-radio-group stacked :id="props.id" :options="visibilityOptions"
v-model="props.field.$model">
</b-form-radio-group>
</template>
</b-wrapped-form-group>
</b-form-row>
</b-form-group>
</div>
</template>
<script>
import BWrappedFormGroup from "~/components/Form/BWrappedFormGroup";
import CommonFormattingInfo from "./CommonFormattingInfo";
export default {
name: 'Twitter',
components: {CommonFormattingInfo, BWrappedFormGroup},
props: {
form: Object
},
computed: {
langSeconds() {
return this.$gettext('%{ seconds } seconds');
},
langMinutes() {
return this.$gettext('%{ minutes } minutes');
},
rateLimitOptions() {
return [
{
text: this.$gettext('No Limit'),
value: 0,
},
{
text: this.$gettextInterpolate(this.langSeconds, {seconds: 15}),
value: 15,
},
{
text: this.$gettextInterpolate(this.langSeconds, {seconds: 30}),
value: 30,
},
{
text: this.$gettextInterpolate(this.langSeconds, {seconds: 60}),
value: 60,
},
{
text: this.$gettextInterpolate(this.langMinutes, {minutes: 2}),
value: 120,
},
{
text: this.$gettextInterpolate(this.langMinutes, {minutes: 5}),
value: 300,
},
{
text: this.$gettextInterpolate(this.langMinutes, {minutes: 10}),
value: 600,
},
{
text: this.$gettextInterpolate(this.langMinutes, {minutes: 15}),
value: 900,
},
{
text: this.$gettextInterpolate(this.langMinutes, {minutes: 30}),
value: 1800,
},
{
text: this.$gettextInterpolate(this.langMinutes, {minutes: 60}),
value: 3600,
}
];
},
visibilityOptions() {
return [
{
text: this.$gettext('Public'),
value: 'public',
},
{
text: this.$gettext('Unlisted'),
value: 'unlisted',
},
{
text: this.$gettext('Private'),
value: 'private',
}
];
}
}
}
</script>

View File

@ -114,4 +114,14 @@ abstract class AbstractConnector implements ConnectorInterface
$pattern = sprintf(UrlValidator::PATTERN, 'http|https');
return (preg_match($pattern, $url)) ? $url : null;
}
protected function incompleteConfigException(): \InvalidArgumentException
{
return new \InvalidArgumentException(
sprintf(
'Webhook %s is missing necessary configuration. Skipping...',
static::NAME
),
);
}
}

View File

@ -29,13 +29,11 @@ interface ConnectorInterface
* @param Entity\StationWebhook $webhook
* @param Entity\Api\NowPlaying\NowPlaying $np
* @param array<string> $triggers
*
* @return bool Whether the webhook actually dispatched.
*/
public function dispatch(
Entity\Station $station,
Entity\StationWebhook $webhook,
Entity\Api\NowPlaying\NowPlaying $np,
array $triggers
): bool;
): void;
}

View File

@ -5,7 +5,6 @@ declare(strict_types=1);
namespace App\Webhook\Connector;
use App\Entity;
use GuzzleHttp\Exception\TransferException;
use Monolog\Level;
/*
@ -72,14 +71,13 @@ final class Discord extends AbstractConnector
Entity\StationWebhook $webhook,
Entity\Api\NowPlaying\NowPlaying $np,
array $triggers
): bool {
): void {
$config = $webhook->getConfig();
$webhook_url = $this->getValidUrl($config['webhook_url'] ?? '');
if (empty($webhook_url)) {
$this->logger->error('Webhook ' . self::NAME . ' is missing necessary configuration. Skipping...');
return false;
throw $this->incompleteConfigException();
}
$raw_vars = [
@ -131,29 +129,22 @@ final class Discord extends AbstractConnector
// Dispatch webhook
$this->logger->debug('Dispatching Discord webhook...');
try {
$response = $this->httpClient->request(
'POST',
$webhook_url,
[
'headers' => [
'Content-Type' => 'application/json',
],
'json' => $webhook_body,
]
);
$response = $this->httpClient->request(
'POST',
$webhook_url,
[
'headers' => [
'Content-Type' => 'application/json',
],
'json' => $webhook_body,
]
);
$this->logger->addRecord(
($response->getStatusCode() !== 204 ? Level::Error : Level::Debug),
sprintf('Webhook %s returned code %d', self::NAME, $response->getStatusCode()),
['message_sent' => $webhook_body, 'response_body' => $response->getBody()->getContents()]
);
} catch (TransferException $e) {
$this->logger->error(sprintf('Error from Discord (%d): %s', $e->getCode(), $e->getMessage()));
return false;
}
return true;
$this->logger->addRecord(
($response->getStatusCode() !== 204 ? Level::Error : Level::Debug),
sprintf('Webhook %s returned code %d', self::NAME, $response->getStatusCode()),
['message_sent' => $webhook_body, 'response_body' => $response->getBody()->getContents()]
);
}
/** @noinspection HttpUrlsUsage */

View File

@ -8,7 +8,6 @@ use App\Entity;
use App\Service\Mail;
use GuzzleHttp\Client;
use Monolog\Logger;
use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
final class Email extends AbstractConnector
{
@ -30,10 +29,9 @@ final class Email extends AbstractConnector
Entity\StationWebhook $webhook,
Entity\Api\NowPlaying\NowPlaying $np,
array $triggers
): bool {
): void {
if (!$this->mail->isEnabled()) {
$this->logger->error('E-mail delivery is not currently enabled. Skipping webhook delivery...');
return false;
throw new \RuntimeException('E-mail delivery is not currently enabled. Skipping webhook delivery...');
}
$config = $webhook->getConfig();
@ -42,32 +40,24 @@ final class Email extends AbstractConnector
$emailBody = $config['message'];
if (empty($emailTo) || empty($emailSubject) || empty($emailBody)) {
$this->logger->error('Webhook ' . self::NAME . ' is missing necessary configuration. Skipping...');
return false;
throw $this->incompleteConfigException();
}
try {
$email = $this->mail->createMessage();
$email = $this->mail->createMessage();
foreach (explode(',', $emailTo) as $emailToPart) {
$email->addTo(trim($emailToPart));
}
$vars = [
'subject' => $emailSubject,
'body' => $emailBody,
];
$vars = $this->replaceVariables($vars, $np);
$email->subject($vars['subject']);
$email->text($vars['body']);
$this->mail->send($email);
} catch (TransportExceptionInterface $e) {
$this->logger->error(sprintf('Error from e-mail (%d): %s', $e->getCode(), $e->getMessage()));
return false;
foreach (explode(',', $emailTo) as $emailToPart) {
$email->addTo(trim($emailToPart));
}
return true;
$vars = [
'subject' => $emailSubject,
'body' => $emailBody,
];
$vars = $this->replaceVariables($vars, $np);
$email->subject($vars['subject']);
$email->text($vars['body']);
$this->mail->send($email);
}
}

View File

@ -5,7 +5,6 @@ declare(strict_types=1);
namespace App\Webhook\Connector;
use App\Entity;
use GuzzleHttp\Exception\TransferException;
final class Generic extends AbstractConnector
{
@ -19,43 +18,35 @@ final class Generic extends AbstractConnector
Entity\StationWebhook $webhook,
Entity\Api\NowPlaying\NowPlaying $np,
array $triggers
): bool {
): void {
$config = $webhook->getConfig();
$webhook_url = $this->getValidUrl($config['webhook_url'] ?? '');
if (empty($webhook_url)) {
$this->logger->error('Webhook ' . self::NAME . ' is missing necessary configuration. Skipping...');
return false;
throw $this->incompleteConfigException();
}
try {
$request_options = [
'headers' => [
'Content-Type' => 'application/json',
],
'json' => $np,
'timeout' => (float)($config['timeout'] ?? 5.0),
$request_options = [
'headers' => [
'Content-Type' => 'application/json',
],
'json' => $np,
'timeout' => (float)($config['timeout'] ?? 5.0),
];
if (!empty($config['basic_auth_username']) && !empty($config['basic_auth_password'])) {
$request_options['auth'] = [
$config['basic_auth_username'],
$config['basic_auth_password'],
];
if (!empty($config['basic_auth_username']) && !empty($config['basic_auth_password'])) {
$request_options['auth'] = [
$config['basic_auth_username'],
$config['basic_auth_password'],
];
}
$response = $this->httpClient->request('POST', $webhook_url, $request_options);
$this->logger->debug(
sprintf('Generic webhook returned code %d', $response->getStatusCode()),
['response_body' => $response->getBody()->getContents()]
);
} catch (TransferException $e) {
$this->logger->error(sprintf('Error from generic webhook (%d): %s', $e->getCode(), $e->getMessage()));
return false;
}
return true;
$response = $this->httpClient->request('POST', $webhook_url, $request_options);
$this->logger->debug(
sprintf('Generic webhook returned code %d', $response->getStatusCode()),
['response_body' => $response->getBody()->getContents()]
);
}
}

View File

@ -31,11 +31,10 @@ final class GoogleAnalytics extends AbstractConnector
Entity\StationWebhook $webhook,
Entity\Api\NowPlaying\NowPlaying $np,
array $triggers
): bool {
): void {
$config = $webhook->getConfig();
if (empty($config['tracking_id'])) {
$this->logger->error('Webhook ' . self::NAME . ' is missing necessary configuration. Skipping...');
return false;
throw $this->incompleteConfigException();
}
// Get listen URLs for each mount point.
@ -95,7 +94,5 @@ final class GoogleAnalytics extends AbstractConnector
}
$analytics->sendEnqueuedHits();
return true;
}
}

View File

@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace App\Webhook\Connector;
use App\Entity;
use App\Utilities\Urls;
/**
* Mastodon web hook connector.
*/
final class Mastodon extends AbstractConnector
{
public const NAME = 'mastodon';
protected function getRateLimitTime(Entity\StationWebhook $webhook): ?int
{
$config = $webhook->getConfig();
$rateLimitSeconds = (int)($config['rate_limit'] ?? 0);
return max(10, $rateLimitSeconds);
}
public function dispatch(
Entity\Station $station,
Entity\StationWebhook $webhook,
Entity\Api\NowPlaying\NowPlaying $np,
array $triggers
): void {
$config = $webhook->getConfig();
$instanceUrl = trim($config['instance_url'] ?? '');
$accessToken = trim($config['access_token'] ?? '');
if (empty($instanceUrl) || empty($accessToken)) {
throw $this->incompleteConfigException();
}
$messages = $this->replaceVariables(
[
'message' => $config['message'] ?? '',
],
$np
);
$instanceUri = Urls::parseUserUrl($instanceUrl, 'Mastodon Instance URL');
$visibility = $config['visibility'] ?? 'public';
$response = $this->httpClient->request(
'POST',
$instanceUri->withPath('/api/v1/statuses'),
[
'headers' => [
'Authorization' => 'Bearer ' . $accessToken,
'Content-Type' => 'application/json',
],
'json' => [
'status' => $messages['message'],
'visibility' => $visibility,
],
]
);
$this->logger->debug(
sprintf('Webhook %s returned code %d', self::NAME, $response->getStatusCode()),
[
'instanceUri' => (string)$instanceUri,
'response' => $response->getBody()->getContents(),
]
);
}
}

View File

@ -8,7 +8,6 @@ use App\Entity;
use App\Http\RouterInterface;
use App\Utilities\Urls;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\TransferException;
use Monolog\Logger;
use Psr\Http\Message\UriInterface;
@ -33,12 +32,11 @@ final class MatomoAnalytics extends AbstractConnector
Entity\StationWebhook $webhook,
Entity\Api\NowPlaying\NowPlaying $np,
array $triggers
): bool {
): void {
$config = $webhook->getConfig();
if (empty($config['matomo_url']) || empty($config['site_id'])) {
$this->logger->error('Webhook ' . self::NAME . ' is missing necessary configuration. Skipping...');
return false;
throw $this->incompleteConfigException();
}
// Get listen URLs for each mount point.
@ -119,21 +117,19 @@ final class MatomoAnalytics extends AbstractConnector
$i++;
if (100 === $i) {
if (!$this->sendBatch($apiUrl, $apiToken, $entries)) {
return false;
}
$this->sendBatch($apiUrl, $apiToken, $entries);
$entries = [];
$i = 0;
}
}
return $this->sendBatch($apiUrl, $apiToken, $entries);
$this->sendBatch($apiUrl, $apiToken, $entries);
}
private function sendBatch(UriInterface $apiUrl, ?string $apiToken, array $entries): bool
private function sendBatch(UriInterface $apiUrl, ?string $apiToken, array $entries): void
{
if (empty($entries)) {
return true;
return;
}
$jsonBody = [
@ -148,20 +144,13 @@ final class MatomoAnalytics extends AbstractConnector
$this->logger->debug('Message body for Matomo API Query', ['body' => $jsonBody]);
try {
$response = $this->httpClient->post($apiUrl, [
'json' => $jsonBody,
]);
$response = $this->httpClient->post($apiUrl, [
'json' => $jsonBody,
]);
$this->logger->debug(
sprintf('Matomo returned code %d', $response->getStatusCode()),
['response_body' => $response->getBody()->getContents()]
);
} catch (TransferException $e) {
$this->logger->error(sprintf('Error from Matomo (%d): %s', $e->getCode(), $e->getMessage()));
return false;
}
return true;
$this->logger->debug(
sprintf('Matomo returned code %d', $response->getStatusCode()),
['response_body' => $response->getBody()->getContents()]
);
}
}

View File

@ -5,7 +5,6 @@ declare(strict_types=1);
namespace App\Webhook\Connector;
use App\Entity;
use GuzzleHttp\Exception\TransferException;
/**
* Telegram web hook connector.
@ -24,15 +23,14 @@ final class Telegram extends AbstractConnector
Entity\StationWebhook $webhook,
Entity\Api\NowPlaying\NowPlaying $np,
array $triggers
): bool {
): void {
$config = $webhook->getConfig();
$bot_token = trim($config['bot_token'] ?? '');
$chat_id = trim($config['chat_id'] ?? '');
if (empty($bot_token) || empty($chat_id)) {
$this->logger->error('Webhook ' . self::NAME . ' is missing necessary configuration. Skipping...');
return false;
throw $this->incompleteConfigException();
}
$messages = $this->replaceVariables(
@ -42,48 +40,33 @@ final class Telegram extends AbstractConnector
$np
);
try {
$api_url = (!empty($config['api'])) ? rtrim($config['api'], '/') : 'https://api.telegram.org';
$webhook_url = $api_url . '/bot' . $bot_token . '/sendMessage';
$api_url = (!empty($config['api'])) ? rtrim($config['api'], '/') : 'https://api.telegram.org';
$webhook_url = $api_url . '/bot' . $bot_token . '/sendMessage';
$request_params = [
'chat_id' => $chat_id,
'text' => $messages['text'],
'parse_mode' => $config['parse_mode'] ?? 'Markdown', // Markdown or HTML
];
$request_params = [
'chat_id' => $chat_id,
'text' => $messages['text'],
'parse_mode' => $config['parse_mode'] ?? 'Markdown', // Markdown or HTML
];
$response = $this->httpClient->request(
'POST',
$webhook_url,
[
'headers' => [
'Content-Type' => 'application/json',
],
'json' => $request_params,
]
);
$response = $this->httpClient->request(
'POST',
$webhook_url,
[
'headers' => [
'Content-Type' => 'application/json',
],
'json' => $request_params,
]
);
$this->logger->debug(
sprintf('Webhook %s returned code %d', self::NAME, $response->getStatusCode()),
[
'request_url' => $webhook_url,
'request_params' => $request_params,
'response_body' => $response->getBody()->getContents(),
]
);
} catch (TransferException $e) {
$this->logger->error(
sprintf(
'Error from webhook %s (%d): %s',
self::NAME,
$e->getCode(),
$e->getMessage()
)
);
return false;
}
return true;
$this->logger->debug(
sprintf('Webhook %s returned code %d', self::NAME, $response->getStatusCode()),
[
'request_url' => $webhook_url,
'request_params' => $request_params,
'response_body' => $response->getBody()->getContents(),
]
);
}
}

View File

@ -5,7 +5,6 @@ declare(strict_types=1);
namespace App\Webhook\Connector;
use App\Entity;
use GuzzleHttp\Exception\TransferException;
final class TuneIn extends AbstractConnector
{
@ -24,40 +23,32 @@ final class TuneIn extends AbstractConnector
Entity\StationWebhook $webhook,
Entity\Api\NowPlaying\NowPlaying $np,
array $triggers
): bool {
): void {
$config = $webhook->getConfig();
if (empty($config['partner_id']) || empty($config['partner_key']) || empty($config['station_id'])) {
$this->logger->error('Webhook ' . self::NAME . ' is missing necessary configuration. Skipping...');
return false;
throw $this->incompleteConfigException();
}
$this->logger->debug('Dispatching TuneIn AIR API call...');
try {
$response = $this->httpClient->get(
'https://air.radiotime.com/Playing.ashx',
[
'query' => [
'partnerId' => $config['partner_id'],
'partnerKey' => $config['partner_key'],
'id' => $config['station_id'],
'title' => $np->now_playing?->song?->title,
'artist' => $np->now_playing?->song?->artist,
'album' => $np->now_playing?->song?->album,
],
]
);
$response = $this->httpClient->get(
'https://air.radiotime.com/Playing.ashx',
[
'query' => [
'partnerId' => $config['partner_id'],
'partnerKey' => $config['partner_key'],
'id' => $config['station_id'],
'title' => $np->now_playing?->song?->title,
'artist' => $np->now_playing?->song?->artist,
'album' => $np->now_playing?->song?->album,
],
]
);
$this->logger->debug(
sprintf('TuneIn returned code %d', $response->getStatusCode()),
['response_body' => $response->getBody()->getContents()]
);
} catch (TransferException $e) {
$this->logger->error(sprintf('Error from TuneIn (%d): %s', $e->getCode(), $e->getMessage()));
return false;
}
return true;
$this->logger->debug(
sprintf('TuneIn returned code %d', $response->getStatusCode()),
['response_body' => $response->getBody()->getContents()]
);
}
}

View File

@ -7,7 +7,6 @@ namespace App\Webhook\Connector;
use App\Entity;
use App\Service\GuzzleFactory;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\TransferException;
use GuzzleHttp\Subscriber\Oauth\Oauth1;
use Monolog\Logger;
@ -39,7 +38,7 @@ final class Twitter extends AbstractConnector
Entity\StationWebhook $webhook,
Entity\Api\NowPlaying\NowPlaying $np,
array $triggers
): bool {
): void {
$config = $webhook->getConfig();
if (
@ -48,8 +47,7 @@ final class Twitter extends AbstractConnector
|| empty($config['token'])
|| empty($config['token_secret'])
) {
$this->logger->error('Webhook ' . self::NAME . ' is missing necessary configuration. Skipping...');
return false;
throw $this->incompleteConfigException();
}
// Set up Twitter OAuth
@ -74,28 +72,21 @@ final class Twitter extends AbstractConnector
// Dispatch webhook
$this->logger->debug('Posting to Twitter...');
try {
$response = $this->httpClient->request(
'POST',
'https://api.twitter.com/1.1/statuses/update.json',
[
'auth' => 'oauth',
'handler' => $stack,
'form_params' => [
'status' => $vars['message'],
],
]
);
$response = $this->httpClient->request(
'POST',
'https://api.twitter.com/1.1/statuses/update.json',
[
'auth' => 'oauth',
'handler' => $stack,
'form_params' => [
'status' => $vars['message'],
],
]
);
$this->logger->debug(
sprintf('Twitter returned code %d', $response->getStatusCode()),
['response_body' => $response->getBody()->getContents()]
);
} catch (TransferException $e) {
$this->logger->error(sprintf('Error from Twitter (%d): %s', $e->getCode(), $e->getMessage()));
return false;
}
return true;
$this->logger->debug(
sprintf('Twitter returned code %d', $response->getStatusCode()),
['response_body' => $response->getBody()->getContents()]
);
}
}

View File

@ -6,14 +6,12 @@ namespace App\Webhook;
use App\Entity;
use App\Environment;
use App\Exception;
use App\Http\RouterInterface;
use App\Message;
use Doctrine\ORM\EntityManagerInterface;
use Monolog\Handler\StreamHandler;
use Monolog\Handler\TestHandler;
use Monolog\Level;
use Monolog\Logger;
use Psr\Log\LogLevel;
final class Dispatcher
{
@ -36,88 +34,108 @@ final class Dispatcher
public function __invoke(Message\AbstractMessage $message): void
{
if ($message instanceof Message\DispatchWebhookMessage) {
$station = $this->em->find(Entity\Station::class, $message->station_id);
if (!$station instanceof Entity\Station) {
return;
$this->handleDispatch($message);
} elseif ($message instanceof Message\TestWebhookMessage) {
$this->testDispatch($message);
}
}
private function handleDispatch(Message\DispatchWebhookMessage $message): void
{
$station = $this->em->find(Entity\Station::class, $message->station_id);
if (!$station instanceof Entity\Station) {
return;
}
$np = $message->np;
$triggers = $message->triggers;
// Always dispatch the special "local" updater task.
$this->localHandler->dispatch($station, $np);
if ($this->environment->isTesting()) {
$this->logger->notice('In testing mode; no webhooks dispatched.');
return;
}
/** @var Entity\StationWebhook[] $enabledWebhooks */
$enabledWebhooks = $station->getWebhooks()->filter(
function (Entity\StationWebhook $webhook) {
return $webhook->getIsEnabled();
}
);
$np = $message->np;
$triggers = (array)$message->triggers;
$this->logger->debug('Webhook dispatch: triggering events: ' . implode(', ', $triggers));
// Always dispatch the special "local" updater task.
$this->localHandler->dispatch($station, $np);
foreach ($enabledWebhooks as $webhook) {
$connectorObj = $this->connectors->getConnector($webhook->getType());
if ($this->environment->isTesting()) {
$this->logger->notice('In testing mode; no webhooks dispatched.');
return;
}
/** @var Entity\StationWebhook[] $enabledWebhooks */
$enabledWebhooks = $station->getWebhooks()->filter(
function (Entity\StationWebhook $webhook) {
return $webhook->getIsEnabled();
}
);
$this->logger->debug('Webhook dispatch: triggering events: ' . implode(', ', $triggers));
foreach ($enabledWebhooks as $webhook) {
$connectorObj = $this->connectors->getConnector($webhook->getType());
if ($connectorObj->shouldDispatch($webhook, $triggers)) {
$this->logger->debug(sprintf('Dispatching connector "%s".', $webhook->getType()));
if ($connectorObj->shouldDispatch($webhook, $triggers)) {
$this->logger->debug(sprintf('Dispatching connector "%s".', $webhook->getType()));
try {
if ($connectorObj->dispatch($station, $webhook, $np, $triggers)) {
$webhook->updateLastSentTimestamp();
$this->em->persist($webhook);
$this->em->flush();
}
} catch (\Throwable $e) {
$this->logger->error(
sprintf(
'%s L%d: %s',
$e->getFile(),
$e->getLine(),
$e->getMessage()
),
[
'exception' => $e,
]
);
}
}
} elseif ($message instanceof Message\TestWebhookMessage) {
$outputPath = $message->outputPath;
}
if (null !== $outputPath) {
$logHandler = new StreamHandler($outputPath, LogLevel::DEBUG, true);
$this->logger->pushHandler($logHandler);
}
$this->em->flush();
}
private function testDispatch(
Message\TestWebhookMessage $message
): void {
$outputPath = $message->outputPath;
if (null !== $outputPath) {
$logHandler = new StreamHandler($outputPath, Level::Debug, true);
$this->logger->pushHandler($logHandler);
}
try {
$webhook = $this->em->find(Entity\StationWebhook::class, $message->webhookId);
if ($webhook instanceof Entity\StationWebhook) {
$this->testDispatch($webhook);
if (!($webhook instanceof Entity\StationWebhook)) {
return;
}
$station = $webhook->getStation();
$np = $this->nowPlayingApiGen->currentOrEmpty($station);
$np->resolveUrls($this->router->getBaseUrl());
$np->cache = 'event';
$connectorObj = $this->connectors->getConnector($webhook->getType());
$connectorObj->dispatch($station, $webhook, $np, [Entity\StationWebhook::TRIGGER_ALL]);
} catch (\Throwable $e) {
$this->logger->error(
sprintf(
'%s L%d: %s',
$e->getFile(),
$e->getLine(),
$e->getMessage()
),
[
'exception' => $e,
]
);
} finally {
if (null !== $outputPath) {
$this->logger->popHandler();
}
}
}
/**
* Send a "test" dispatch of the web hook, regardless of whether it is currently enabled, and
* return any logging information this yields.
*
* @param Entity\StationWebhook $webhook
*
* @throws Exception
*/
public function testDispatch(
Entity\StationWebhook $webhook
): TestHandler {
$station = $webhook->getStation();
$handler = new TestHandler(LogLevel::DEBUG, true);
$this->logger->pushHandler($handler);
$np = $this->nowPlayingApiGen->currentOrEmpty($station);
$np->resolveUrls($this->router->getBaseUrl());
$np->cache = 'event';
$connectorObj = $this->connectors->getConnector($webhook->getType());
$connectorObj->dispatch($station, $webhook, $np, [Entity\StationWebhook::TRIGGER_ALL]);
$this->logger->popHandler();
return $handler;
}
}

View File

@ -63,16 +63,5 @@ final class LocalWebhookHandler
$staticNpPath,
json_encode($np, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) ?: ''
);
// Send Nchan notification.
$this->logger->debug('Dispatching Nchan notification...');
$this->httpClient->post(
$this->environment->getInternalUri()
->withPath('/pub/' . urlencode($station->getShortName())),
[
'json' => $np,
]
);
}
}