From 5967e3df4663a6bc4f2332debf19d6ebdf0b706b Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Fri, 5 Apr 2024 12:07:41 -0400 Subject: [PATCH] feat: Implement notifications via Apprise --- README.md | 2 +- docsite/docs/configuration/configuration.md | 19 +++ docsite/src/pages/index.mdx | 2 +- .../infrastructure/config/health/webhooks.ts | 34 +++- src/backend/common/schema/aio.json | 86 ++++++++++ .../notifier/AbstractWebhookNotifier.ts | 11 +- .../notifier/AppriseWebhookNotifier.ts | 155 ++++++++++++++++++ src/backend/notifier/Notifiers.ts | 7 +- 8 files changed, 307 insertions(+), 9 deletions(-) create mode 100644 src/backend/notifier/AppriseWebhookNotifier.ts diff --git a/README.md b/README.md index 718a841c..6a16e4ba 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ A javascript app to scrobble music you listened to, to [Maloja](https://github.c * [Maloja](/docsite/docs/configuration/configuration.md#maloja) * [Last.fm](/docsite/docs/configuration/configuration.md#lastfm) * [ListenBrainz](/docsite/docs/configuration/configuration.md#listenbrainz) -* Monitor status of Sources and Clients using [webhooks (Gotify or Ntfy)](/docsite/docs/configuration/configuration.md#webhook-configurations) or [healthcheck endpoint](/docsite/docs/configuration/configuration.md#health-endpoint) +* Monitor status of Sources and Clients using [webhooks (Gotify, Ntfy, Apprise)](/docsite/docs/configuration/configuration.md#webhook-configurations) or [healthcheck endpoint](/docsite/docs/configuration/configuration.md#health-endpoint) * Supports configuring for single or multiple users (scrobbling for your friends and family!) * Web server interface for stats, basic control, and detailed logs * Graceful network and client failure handling (queued scrobbles that auto-retry) diff --git a/docsite/docs/configuration/configuration.md b/docsite/docs/configuration/configuration.md index fd882201..73903bcb 100644 --- a/docsite/docs/configuration/configuration.md +++ b/docsite/docs/configuration/configuration.md @@ -983,6 +983,25 @@ EX } ``` +### [Apprise](https://github.com/caronc/apprise-api) + +Refer to the [config schema for AppriseConfig](https://json-schema.app/view/%23/%23%2Fdefinitions%2FAppriseConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fmaster%2Fsrc%2Fbackend%2Fcommon%2Fschema%2Faio.json) + +multi-scrobbler supports [stateless](https://github.com/caronc/apprise-api?tab=readme-ov-file#stateless-solution) and [persistent storage](https://github.com/caronc/apprise-api?tab=readme-ov-file#persistent-storage-solution) endpoints as well as [tags](https://github.com/caronc/apprise-api?tab=readme-ov-file#tagging)/ + +EX + +```json5 +{ + "type": "apprise", + "name": "MyAppriseFriendlyNameForLogs", + "host": "http://192.168.0.100:8080", + "urls": ["gotify://192.168.0.101:8070/MyToken"], // stateless endpoints + "keys": ["e90b20526808373353afad7fb98a201198c0c3e0555bea19f182df3388af7b17"], //persistent storage endpoints + "tags": ["my","optional","tags"] +} +``` + ## Health Endpoint An endpoint for monitoring the health of sources/clients is available at GET `http://YourMultiScrobblerDomain/health` diff --git a/docsite/src/pages/index.mdx b/docsite/src/pages/index.mdx index f8ac6e18..982d95ef 100644 --- a/docsite/src/pages/index.mdx +++ b/docsite/src/pages/index.mdx @@ -31,7 +31,7 @@ A javascript app to scrobble music you listened to, to [Maloja](https://github.c * [Maloja](docs/configuration#maloja) * [Last.fm](docs/configuration#lastfm) * [ListenBrainz](docs/configuration#listenbrainz) -* Monitor status of Sources and Clients using [webhooks (Gotify or Ntfy)](docs/configuration#webhook-configurations) or [healthcheck endpoint](docs/configuration#health-endpoint) +* Monitor status of Sources and Clients using [webhooks (Gotify, Ntfy, Apprise)](docs/configuration#webhook-configurations) or [healthcheck endpoint](docs/configuration#health-endpoint) * Supports configuring for single or multiple users (scrobbling for your friends and family!) * Web server interface for stats, basic control, and detailed logs * Graceful network and client failure handling (queued scrobbles that auto-retry) diff --git a/src/backend/common/infrastructure/config/health/webhooks.ts b/src/backend/common/infrastructure/config/health/webhooks.ts index 128b7432..932bddee 100644 --- a/src/backend/common/infrastructure/config/health/webhooks.ts +++ b/src/backend/common/infrastructure/config/health/webhooks.ts @@ -1,9 +1,11 @@ export interface WebhookPayload { title?: string message: string - priority: 'info' | 'warn' | 'error' + priority: Priority } +export type Priority = 'info' | 'warn' | 'error'; + export interface PrioritiesConfig { /** * @examples [5] @@ -28,7 +30,7 @@ export interface CommonWebhookConfig { * * @examples ["gotify"] * */ - type: 'gotify' | 'ntfy' + type: 'gotify' | 'ntfy' | 'apprise' /** * A friendly name used to identify webhook config in logs * */ @@ -90,4 +92,30 @@ export interface NtfyConfig extends CommonWebhookConfig { priorities?: PrioritiesConfig } -export type WebhookConfig = GotifyConfig | NtfyConfig; +export interface AppriseConfig extends CommonWebhookConfig { + /** + * The URL of the apprise-api server + * + * @examples ["http://192.168.0.100:8078"] + * */ + host: string + + /** + * If using [Stateless Endpoints](https://github.com/caronc/apprise-api?tab=readme-ov-file#stateless-solution) the Apprise config URL(s) to send + * */ + urls?: string | string[] + + /** + * If using [Persistent Store Endpoints](https://github.com/caronc/apprise-api?tab=readme-ov-file#persistent-storage-solution) the Configuration ID(s) to send to + * + * Note: If multiple keys are defined then MS will attempt to POST to each one individually + * */ + keys?: string | string[] + + /** + * Optional [tag(s)](https://github.com/caronc/apprise-api?tab=readme-ov-file#tagging) to send in the notification payload + * */ + tags?: string | string[] +} + +export type WebhookConfig = GotifyConfig | NtfyConfig | AppriseConfig; diff --git a/src/backend/common/schema/aio.json b/src/backend/common/schema/aio.json index dedb8251..7c2e180f 100644 --- a/src/backend/common/schema/aio.json +++ b/src/backend/common/schema/aio.json @@ -1,6 +1,87 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "definitions": { + "AppriseConfig": { + "properties": { + "host": { + "description": "The URL of the apprise-api server", + "examples": [ + "http://192.168.0.100:8078" + ], + "title": "host", + "type": "string" + }, + "keys": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "string" + } + ], + "description": "If using [Persistent Store Endpoints](https://github.com/caronc/apprise-api?tab=readme-ov-file#persistent-storage-solution) the Configuration ID(s) to send to\n\nNote: If multiple keys are defined then MS will attempt to POST to each one individually", + "title": "keys" + }, + "name": { + "description": "A friendly name used to identify webhook config in logs", + "title": "name", + "type": "string" + }, + "tags": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "string" + } + ], + "description": "Optional [tag(s)](https://github.com/caronc/apprise-api?tab=readme-ov-file#tagging) to send in the notification payload", + "title": "tags" + }, + "type": { + "description": "Webhook type. Valid values are:\n\n* gotify\n* ntfy", + "enum": [ + "apprise", + "gotify", + "ntfy" + ], + "examples": [ + "gotify" + ], + "title": "type", + "type": "string" + }, + "urls": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "string" + } + ], + "description": "If using [Stateless Endpoints](https://github.com/caronc/apprise-api?tab=readme-ov-file#stateless-solution) the Apprise config URL(s) to send", + "title": "urls" + } + }, + "required": [ + "host", + "type" + ], + "title": "AppriseConfig", + "type": "object" + }, "ChromecastData": { "properties": { "allowUnknownMedia": { @@ -602,6 +683,7 @@ "type": { "description": "Webhook type. Valid values are:\n\n* gotify\n* ntfy", "enum": [ + "apprise", "gotify", "ntfy" ], @@ -2170,6 +2252,7 @@ "type": { "description": "Webhook type. Valid values are:\n\n* gotify\n* ntfy", "enum": [ + "apprise", "gotify", "ntfy" ], @@ -3070,6 +3153,9 @@ }, { "$ref": "#/definitions/NtfyConfig" + }, + { + "$ref": "#/definitions/AppriseConfig" } ], "title": "WebhookConfig" diff --git a/src/backend/notifier/AbstractWebhookNotifier.ts b/src/backend/notifier/AbstractWebhookNotifier.ts index 4181a09b..089b1cdb 100644 --- a/src/backend/notifier/AbstractWebhookNotifier.ts +++ b/src/backend/notifier/AbstractWebhookNotifier.ts @@ -1,16 +1,21 @@ import { childLogger, Logger } from "@foxxmd/logging"; -import { GotifyConfig, NtfyConfig, WebhookPayload } from "../common/infrastructure/config/health/webhooks.js"; +import { + AppriseConfig, + GotifyConfig, + NtfyConfig, + WebhookPayload +} from "../common/infrastructure/config/health/webhooks.js"; export abstract class AbstractWebhookNotifier { - config: GotifyConfig | NtfyConfig + config: GotifyConfig | NtfyConfig | AppriseConfig logger: Logger; initialized: boolean = false; requiresAuth: boolean = false; authed: boolean = false; - protected constructor(type: string, defaultName: string, config: GotifyConfig | NtfyConfig, logger: Logger) { + protected constructor(type: string, defaultName: string, config: GotifyConfig | NtfyConfig | AppriseConfig, logger: Logger) { this.config = config; const label = `${type} - ${config.name ?? defaultName}` this.logger = childLogger(logger, label); diff --git a/src/backend/notifier/AppriseWebhookNotifier.ts b/src/backend/notifier/AppriseWebhookNotifier.ts new file mode 100644 index 00000000..6697b659 --- /dev/null +++ b/src/backend/notifier/AppriseWebhookNotifier.ts @@ -0,0 +1,155 @@ +import { Logger } from "@foxxmd/logging"; +import request, { Request } from "superagent"; +import { truncateStringToLength } from "../../core/StringUtils.js"; +import { isSuperAgentResponseError } from "../common/errors/ErrorUtils.js"; +import { isNodeNetworkException } from "../common/errors/NodeErrors.js"; +import { UpstreamError } from "../common/errors/UpstreamError.js"; +import { + AppriseConfig, + PrioritiesConfig, + Priority, + WebhookPayload +} from "../common/infrastructure/config/health/webhooks.js"; +import { AbstractWebhookNotifier } from "./AbstractWebhookNotifier.js"; + +const shortKey = truncateStringToLength(10); + +export class AppriseWebhookNotifier extends AbstractWebhookNotifier { + + declare config: AppriseConfig; + + priorities: PrioritiesConfig; + + urls: string[]; + keys: string[]; + + constructor(defaultName: string, config: AppriseConfig, logger: Logger) { + super('Apprise', defaultName, config, logger); + const { + urls = [], + keys = [], + host, + } = this.config; + if (host === undefined) { + throw new Error(`'host' must be defined in configuration for this notification`); + } + this.urls = Array.isArray(urls) ? urls : [urls]; + this.keys = Array.isArray(keys) ? keys : [keys]; + + if (this.urls.length === 0 && this.keys.length === 0) { + this.logger.warn(`No 'urls' or 'keys' were defined! Will assume stateless (POST ${host}/notify) and that you have the ENV 'APPRISE_STATELESS_URLS' set on your Apprise instance`); + } + } + + initialize = async () => { + // check url is correct + try { + await request.get(this.config.host); + } catch (e) { + this.logger.error(new Error('Failed to contact Apprise server', {cause: e})); + } + + if (this.keys.length > 0) { + let anyOk = false; + for (const key of this.keys) { + try { + const resp = await request.get(`${this.config.host}/json/urls/${key}`); + if (resp.statusCode === 204) { + this.logger.warn(`Details for Config ${shortKey(key)} returned no content. Double check the key is set correctly or that the apprise Config is not empty.`); + } else { + anyOk = true; + } + } catch (e) { + this.logger.warn(new Error(`Failed to get details for Config ${shortKey(key)}`, {cause: e})); + } + } + if (!anyOk) { + this.logger.error('No Apprise Configs were valid!'); + this.initialized = false; + return; + } + } + this.initialized = true; + } + + doNotify = async (payload: WebhookPayload) => { + const body: Record = { + title: payload.title, + body: payload.message, + type: convertPriorityToType(payload.priority) + } + + let anyOk = false; + if (this.keys.length > 0) { + for (const key of this.keys) { + try { + const resp = await this.callApi(request.post(`${this.config.host}/notify/${key}`) + .type('json') + .send(body)); + anyOk = true; + this.logger.debug(`Pushed notification to Config ${shortKey(key)}`); + } catch (e: any) { + this.logger.warn(new Error(`Failed to push notification for '${payload.title}' to Config ${shortKey(key)}`, {cause: e})); + } + } + } + + if (this.urls.length > 0 || this.keys.length === 0) { + if (this.urls.length > 0) { + body.urls = this.urls.join(',') + } + try { + const resp = await this.callApi(request.post(`${this.config.host}/notify`) + .type('json') + .send(body)); + anyOk = true; + this.logger.debug(`Pushed notification to URLs`); + } catch (e: any) { + this.logger.warn(`Failed to push notification for '${payload.title}' to URLs`, {cause: e}); + } + } + + if (!anyOk) { + this.logger.error(`Failed to push any notifications!`) + } + } + + callApi = async (req: Request, retries = 0): Promise => { + try { + return await req as T; + } catch (e) { + if (isNodeNetworkException(e) || isSuperAgentResponseError(e) && e.timeout) { + throw new UpstreamError('Request failed to due a network issue', {cause: e}); + } else if (isSuperAgentResponseError(e)) { + const { + message, + status, + response: { + body: jsonBody = undefined, + text = undefined, + } = {} + } = e; + const errorMsgs = [message]; + if (typeof jsonBody === 'object' && jsonBody.error !== undefined) { + errorMsgs.push(jsonBody.error); + } + throw new UpstreamError(`Apprise API Request failed => (${status}) ${errorMsgs.join(' => ')}`, {response: e.response}); + } else { + throw new Error('Non API Request error encountered', {cause: e}); + } + } + } +} + +const convertPriorityToType = (priority?: Priority): 'info' | 'success' | 'warning' | 'failure' => { + switch (priority) { + case 'info': + return 'info'; + case 'warn': + return 'warning'; + case 'error': + return 'failure'; + default: + return 'info'; + } +} diff --git a/src/backend/notifier/Notifiers.ts b/src/backend/notifier/Notifiers.ts index 8fae13b2..7af71e16 100644 --- a/src/backend/notifier/Notifiers.ts +++ b/src/backend/notifier/Notifiers.ts @@ -1,12 +1,14 @@ import { childLogger, Logger } from '@foxxmd/logging'; import { EventEmitter } from "events"; import { + AppriseConfig, GotifyConfig, NtfyConfig, WebhookConfig, WebhookPayload } from "../common/infrastructure/config/health/webhooks.js"; import { AbstractWebhookNotifier } from "./AbstractWebhookNotifier.js"; +import { AppriseWebhookNotifier } from "./AppriseWebhookNotifier.js"; import { GotifyWebhookNotifier } from "./GotifyWebhookNotifier.js"; import { NtfyWebhookNotifier } from "./NtfyWebhookNotifier.js"; @@ -26,7 +28,7 @@ export class Notifiers { this.clientEmitter = clientEmitter; this.sourceEmitter = sourceEmitter; - this.logger = childLogger(parentLogger, 'Notifiers'); // winston.loggers.get('app').child({labels: ['Notifiers']}, mergeArr); + this.logger = childLogger(parentLogger, 'Notifiers'); this.sourceEmitter.on('notify', async (payload: WebhookPayload) => { await this.notify(payload); @@ -44,6 +46,9 @@ export class Notifiers { case 'ntfy': webhook = new NtfyWebhookNotifier(defaultName, config as NtfyConfig, this.logger); break; + case 'apprise': + webhook = new AppriseWebhookNotifier(defaultName, config as AppriseConfig, this.logger); + break; default: this.logger.error(`'${config.type}' is not a valid webhook type`); continue; -- 2.51.2