Commit 71617d3f authored by Aerion's avatar Aerion Committed by Benjamin Bouvier
Browse files

add alert notifications for multiple providers via apprise-api

apprise-api is a new external optional dependency. It's a web api for
sending messages to different providers (to be used as notifications).

Alerts could be received only via email before. With this commit, a
new set of providers is available thanks to the usage of
apprise-api.

In order for the alerts to be received, the following must be
respected:
* admin: apprise-api must be running and configured in the server settings
* user:  in the alerts configuration, the user must specify its
  apprise url, specific to its provider. The format of an apprise url
  for a provider can be found here:
  https://github.com/caronc/apprise/wiki#notification-services
parent 73baf1e5
......@@ -5,7 +5,8 @@ import { get } from '../../../store';
import { translate as $t } from '../../../helpers';
import Alerts from './alert-list';
import EmailConfig from './config';
import EmailConfig from './email-config';
import NotificationsConfig from './notifications-config';
import Reports from './report-list';
import DisplayIf from '../../ui/display-if';
......@@ -13,6 +14,8 @@ function EmailsParameters(props) {
return (
<div className="emails settings-container">
<EmailConfig />
<hr />
<NotificationsConfig />
<DisplayIf condition={props.enableEditors}>
<hr />
<div>
......@@ -41,10 +44,12 @@ function EmailsParameters(props) {
export default connect(state => {
// Only enable the editors if emails are enabled and a recipient email
// address has been set.
// address has been set or if notifications are enabled.
let enableEditors =
get.boolSetting(state, 'emails-enabled') &&
get.setting(state, 'email-recipient').length > 0;
(get.boolSetting(state, 'emails-enabled') &&
get.setting(state, 'email-recipient').length > 0) ||
(get.boolSetting(state, 'notifications-enabled') &&
get.setting(state, 'apprise-url').length > 0);
return {
enableEditors
};
......
import React from 'react';
import { connect } from 'react-redux';
import { actions, get } from '../../../store';
import { translate as $t, notify } from '../../../helpers';
import ClearableInput from '../../ui/clearable-input';
import ExternalLink from '../../ui/external-link';
class NotificationsConfig extends React.Component {
state = {
appriseUrl: this.props.appriseUrl
};
handleAppriseUrlChange = value => {
this.setState({ appriseUrl: value });
};
handleSubmit = event => {
event.preventDefault();
this.props.saveNotification(this.state.appriseUrl);
};
handleSendTestNotification = () => {
if (!this.state.appriseUrl) {
return;
}
this.props.sendTestNotification(this.state.appriseUrl);
};
render() {
if (!this.props.notificationsEnabled) {
return <div>{$t('client.settings.notifications.notifications_not_enabled')}</div>;
}
return (
<form onSubmit={this.handleSubmit} className="settings-form">
<p className="alerts info">
<span className="fa fa-question-circle" />
{$t('client.settings.notifications.apprise_description')}
&nbsp;
{/* eslint-disable-next-line max-len */}
<ExternalLink href="https://github.com/caronc/apprise/wiki#notification-services">
{$t('client.settings.notifications.apprise_description_link')}
</ExternalLink>
</p>
<div className="wrap-on-mobile">
<label htmlFor="apprise_url">
{$t('client.settings.notifications.apprise_url')}
</label>
<ClearableInput
id="apprise_url"
type="text"
value={this.state.appriseUrl}
placeholder="gotify://gotify.server.local/abcdefghijklmn"
onChange={this.handleAppriseUrlChange}
/>
</div>
<p className="buttons-toolbar">
<button
type="button"
className="btn"
disabled={this.props.sendingNotification}
onClick={this.handleSendTestNotification}>
{$t('client.settings.notifications.send_test_notification')}
</button>
<button type="submit" className="btn primary">
{$t('client.settings.submit')}
</button>
</p>
</form>
);
}
}
export default connect(
state => {
return {
notificationsEnabled: get.boolSetting(state, 'notifications-enabled'),
appriseUrl: get.setting(state, 'apprise-url'),
sendingNotification: get.isSendingTestNotification(state)
};
},
dispatch => {
return {
saveNotification: appriseUrl => actions.setSetting(dispatch, 'apprise-url', appriseUrl),
async sendTestNotification(appriseUrl) {
try {
await actions.sendTestNotification(dispatch, appriseUrl);
notify.success(
$t('client.settings.notifications.send_test_notification_success')
);
} catch (err) {
notify.error(
$t('client.settings.notifications.send_test_notification_error', {
error: err.message
})
);
}
}
};
}
)(NotificationsConfig);
......@@ -43,6 +43,7 @@ export const RESET_BUDGETS = 'RESET_BUDGETS';
export const EXPORT_INSTANCE = 'EXPORT_INSTANCE';
export const IMPORT_INSTANCE = 'IMPORT_INSTANCE';
export const SEND_TEST_EMAIL = 'SEND_TEST_EMAIL';
export const SEND_TEST_NOTIFICATION = 'SEND_TEST_NOTIFICATION';
export const SET_DEFAULT_ACCOUNT = 'SET_DEFAULT_ACCOUNT';
export const SET_SETTING = 'SET_SETTING';
export const UPDATE_ACCESS = 'UPDATE_ACCESS';
......
......@@ -249,6 +249,16 @@ export function sendTestEmail(email) {
});
}
export function sendTestNotification(appriseUrl) {
return buildFetchPromise(`api/${API_VERSION}/settings/test-notification/`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ appriseUrl })
});
}
export function createAccess(vendorId, login, password, customFields, customLabel) {
let data = {
vendorId,
......
......@@ -314,6 +314,12 @@ export const get = {
return Ui.isSendingTestEmail(state.ui);
},
// Bool
isSendingTestNotification(state) {
assertDefined(state);
return Ui.isSendingTestNotification(state.ui);
},
// Bool
isSmallScreen(state) {
assertDefined(state);
......@@ -510,6 +516,11 @@ export const actions = {
return dispatch(Settings.sendTestEmail(email));
},
sendTestNotification(dispatch, appriseUrl) {
assertDefined(dispatch);
return dispatch(Settings.sendTestNotification(appriseUrl));
},
runAccountsSync(dispatch, accessId) {
assertDefined(dispatch);
dispatch(Bank.runAccountsSync(accessId));
......
......@@ -17,6 +17,7 @@ import { createReducerFromMap, fillOutcomeHandlers, SUCCESS, FAIL } from './help
import {
EXPORT_INSTANCE,
SEND_TEST_EMAIL,
SEND_TEST_NOTIFICATION,
SET_SETTING,
UPDATE_ACCESS,
UPDATE_ACCESS_AND_FETCH,
......@@ -58,6 +59,12 @@ const basic = {
};
},
sendTestNotification() {
return {
type: SEND_TEST_NOTIFICATION
};
},
set(key, value) {
return {
type: SET_SETTING,
......@@ -159,6 +166,21 @@ export function sendTestEmail(email) {
};
}
export function sendTestNotification(appriseUrl) {
return dispatch => {
dispatch(basic.sendTestNotification());
return backend
.sendTestNotification(appriseUrl)
.then(() => {
dispatch(success.sendTestNotification());
})
.catch(err => {
dispatch(fail.sendTestNotification(err));
throw err;
});
};
}
export function set(key, value) {
assert(typeof key === 'string', 'key must be a string');
assert(typeof value === 'string', 'value must be a string');
......
......@@ -188,6 +188,16 @@ function reduceSendTestEmail(state, action) {
return u({ sendingTestEmail: true }, state);
}
function reduceSendTestNotification(state, action) {
let { status } = action;
if (status === SUCCESS || status === FAIL) {
return u({ sendingTestNotification: false }, state);
}
return u({ sendingTestNotification: true }, state);
}
function reduceExportInstance(state, action) {
let { status } = action;
......@@ -260,6 +270,7 @@ const reducers = {
RUN_OPERATIONS_SYNC: makeProcessingReasonReducer('client.spinner.sync'),
RUN_APPLY_BULKEDIT: makeProcessingReasonReducer('client.spinner.apply'),
SEND_TEST_EMAIL: reduceSendTestEmail,
SEND_TEST_NOTIFICATION: reduceSendTestNotification,
SET_SEARCH_FIELD: reduceSetSearchField,
SET_SEARCH_FIELDS: reduceSetSearchFields,
TOGGLE_SEARCH_DETAILS: reduceToggleSearchDetails,
......@@ -280,6 +291,7 @@ const uiState = u({
processingReason: 'client.general.loading_assets',
updatingWeboob: false,
sendingTestEmail: false,
sendingTestNotification: false,
isDemoMode: false
});
......@@ -307,6 +319,7 @@ export function initialState(isDemoEnabled) {
processingReason: 'client.general.loading_assets',
updatingWeboob: false,
sendingTestEmail: false,
sendingTestNotification: false,
isDemoMode: isDemoEnabled,
isExporting: false,
isSmallScreen: computeIsSmallScreen(),
......@@ -355,6 +368,10 @@ export function isSendingTestEmail(state) {
return state.sendingTestEmail;
}
export function isSendingTestNotification(state) {
return state.sendingTestNotification;
}
export function isExporting(state) {
return state.isExporting;
}
......
......@@ -299,6 +299,18 @@ let OPTIONS = [
doc: 'If set to false, will allow self-signed TLS certificates.'
},
{
envName: 'KRESUS_APPRISE_API_BASE_URL',
configPath: 'config.notifications.appriseApiBaseUrl',
defaultVal: null,
processPath: 'appriseApiBaseUrl',
doc: `The baseurl from which apprise-api will be called for
notifications to be sent.
See https://github.com/caronc/apprise-api#installation for
installation`,
docExample: 'http://localhost:8000/'
},
{
envName: 'KRESUS_AUTH',
configPath: 'config.kresus.auth',
......
......@@ -23,7 +23,8 @@ import {
KError,
asyncErr,
getErrorCode,
UNKNOWN_OPERATION_TYPE
UNKNOWN_OPERATION_TYPE,
isAppriseApiEnabled
} from '../../helpers';
import { cleanData } from './helpers';
......@@ -72,7 +73,7 @@ async function getAllData(userId, isExport = false, cleanPassword = true) {
ret.budgets = (await Budgets.all(userId)).map(cleanMeta);
}
if (isExport || isEmailEnabled()) {
if (isExport || isEmailEnabled() || isAppriseApiEnabled()) {
ret.alerts = (await Alerts.all(userId)).map(cleanMeta);
} else {
ret.alerts = [];
......
......@@ -108,6 +108,9 @@ const routes = {
'settings/test-email': {
post: settings.testEmail
},
'settings/test-notification': {
post: settings.testNotification
},
alertId: {
param: alerts.loadAlert
......
......@@ -2,6 +2,7 @@ import { Settings } from '../../models';
import * as weboob from '../../lib/sources/weboob';
import getEmailer from '../../lib/emailer';
import getNotifier, { sendTestNotification } from '../../lib/notifications';
import { WEBOOB_NOT_INSTALLED } from '../../shared/errors.json';
import {
......@@ -12,11 +13,14 @@ import {
UNKNOWN_WEBOOB_VERSION
} from '../../helpers';
function postSave(key, value) {
function postSave(userId, key, value) {
switch (key) {
case 'email-recipient':
getEmailer().forceReinit(value);
break;
case 'apprise-url':
getNotifier(userId).forceReinit(value);
break;
case 'locale':
setupTranslator(value);
break;
......@@ -38,7 +42,7 @@ export async function save(req, res) {
let { id: userId } = req.user;
await Settings.updateByKey(userId, pair.key, pair.value);
postSave(pair.key, pair.value);
postSave(userId, pair.key, pair.value);
res.status(200).end();
} catch (err) {
......@@ -85,6 +89,19 @@ export async function testEmail(req, res) {
}
}
export async function testNotification(req, res) {
try {
let { appriseUrl } = req.body;
if (!appriseUrl) {
throw new KError('Missing apprise url when sending a notification', 400);
}
await sendTestNotification(appriseUrl);
res.status(200).end();
} catch (err) {
return asyncErr(res, err, 'when trying to send a notification');
}
}
export function isDemoForced() {
return process.kresus.forceDemoMode === true;
}
......
......@@ -184,6 +184,10 @@ export const isEmailEnabled = (): boolean => {
);
};
export const isAppriseApiEnabled = (): boolean => {
return !!(process.kresus.appriseApiBaseUrl && process.kresus.appriseApiBaseUrl.length);
};
export function normalizeVersion(version: string | null): string | null {
if (version === null) {
return null;
......
......@@ -10,7 +10,6 @@ import {
KError,
getErrorCode,
makeLogger,
translate as $t,
currency,
assert,
displayLabel,
......@@ -21,7 +20,6 @@ import {
import AsyncQueue from './async-queue';
import alertManager from './alert-manager';
import Notifications from './notifications';
import diffAccounts from './diff-accounts';
import diffTransactions from './diff-transactions';
import filterDuplicateTransactions from './filter-duplicate-transactions';
......@@ -182,8 +180,6 @@ async function notifyNewOperations(access, newOperations, accountMap) {
let formatCurrency = await account.getCurrencyFormatter();
params.operation_details = `${ops[0].label} ${formatCurrency(ops[0].amount)}`;
}
Notifications.send($t('server.notification.new_operation', params));
/* eslint-enable camelcase */
}
}
......
......@@ -2,7 +2,7 @@ import { makeLogger, translate as $t, displayLabel } from '../helpers';
import { Accounts, Alerts } from '../models';
import Notifications from './notifications';
import getNotifier from './notifications';
import getEmailer from './emailer';
let log = makeLogger('alert-manager');
......@@ -19,7 +19,7 @@ ${$t('server.email.signature')}
}
async send(userId, { subject, text }) {
Notifications.send(text);
await getNotifier(userId).send(subject, text);
// Send email notification
let content = this.wrapContent(text);
......
......@@ -8,6 +8,7 @@ export const ConfigGhostSettings = new Set([
'standalone-mode',
'url-prefix',
'emails-enabled',
'notifications-enabled',
'can-encrypt',
'force-demo-mode'
]);
import { makeLogger } from '../helpers';
import { resolve } from 'url';
import { makeLogger, translate as $t, KError, isAppriseApiEnabled } from '../helpers';
import Settings from '../models/entities/settings';
import fetch from 'node-fetch';
let log = makeLogger('notifications');
class Notifier {
appriseApiBaseUrl;
enabled;
constructor() {
log.warn('Notification module is NYI.');
this.enabled = isAppriseApiEnabled();
this.appriseApiBaseUrl = resolve(process.kresus.appriseApiBaseUrl, '/notify');
}
send(text) {
log.warn('Sending a notification is NYI.');
/**
* @param opts {{appriseUrl: string, subject: string, content: string}}
*/
_send(opts) {
if (process.env.NODE_ENV !== 'production') {
log.warn(`Text: ${text}`);
log.warn(`Notification: Subject: ${opts.subject}; Content: ${opts.content}`);
}
if (!this.enabled) {
log.warn("AppriseApiBaseUrl is missing: notifications won't work.");
return;
}
const body = {
urls: opts.appriseUrl,
title: opts.subject,
body: opts.content
};
return fetch(this.appriseApiBaseUrl, {
method: 'POST',
body: JSON.stringify(body),
headers: { 'Content-Type': 'application/json' }
}).then(res => {
if (!res.ok) {
throw new KError(
"Couldn't send notification with apprise",
res.status,
res.statusText
);
}
});
}
async sendTestNotification(appriseUrl) {
await this._send({
appriseUrl,
subject: $t('server.notification.test_notification.subject'),
content: $t('server.notification.test_notification.content')
});
}
}
let NOTIFIER = null;
function _getBaseNotifier() {
if (NOTIFIER === null) {
NOTIFIER = new Notifier();
}
return NOTIFIER;
}
class UserNotifier {
appriseUserUrl;
userId;
constructor(userId) {
this.userId = userId;
}
forceReinit(appriseUserUrl) {
this.appriseUserUrl = appriseUserUrl;
}
async ensureInit() {
if (this.appriseUserUrl) {
return;
}
this.forceReinit((await Settings.findOrCreateDefault(this.userId, 'apprise-url')).value);
log.info(`Apprise url fetched for user ${this.userId}`);
}
async send(subject, content) {
await this.ensureInit();
if (!subject) {
return log.warn('Notifier.send misuse: subject is required');
}
if (!content) {
return log.warn('Notifier.send misuse: content is required');
}
await _getBaseNotifier()._send({ subject, content, appriseUrl: this.appriseUserUrl });
}
}
let NOTIFIER_PER_USER_ID = {};
function getNotifier(userId) {
if (!(userId in NOTIFIER_PER_USER_ID)) {
log.info(`Notifier initialized for user ${userId}`);
NOTIFIER_PER_USER_ID[userId] = new UserNotifier(userId);
}
return NOTIFIER_PER_USER_ID[userId];
}
export function sendTestNotification(appriseUrl) {
return _getBaseNotifier().sendTestNotification(appriseUrl);
}
export default new Notifier();
export default getNotifier;
......@@ -5,7 +5,7 @@ import { Accesses, Settings } from '../models';
import accountManager from './accounts-manager';
import Cron from './cron';
import ReportManager from './report-manager';
import getEmailer from './emailer';
import AlertManager from './alert-manager';
import { bankVendorByUuid } from './bank-vendors';
import {
......@@ -45,9 +45,9 @@ async function manageCredentialsErrors(userId, access, err) {