Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ See [CONTRIBUTING.md](CONTRIBUTING.md)
- [Extended Settings](#extended-settings)
- [Pushover](#pushover)
- [IFTTT Maker](#ifttt-maker)
- [Custom Notification WebHooks](#custom-notification-webhooks)
- [Treatment Profile](#treatment-profile)
- [Setting environment variables](#setting-environment-variables)
- [Vagrant install](#vagrant-install)
Expand Down Expand Up @@ -773,6 +774,57 @@ For remote overrides, the following extended settings must be configured:
* `ns-urgent` - Alarms at the urgent level with cause this event to also be triggered. It will be sent in addition to `ns-event`.
* see the [full list of events](docs/plugins/maker-setup.md#events)

#### Custom Notification WebHooks

Custom notification webhooks deliver Nightscout alarms and notifications straight to an endpoint you control, instead of relaying them through IFTTT. Because the request goes directly to your endpoint, it is not subject to IFTTT's throttling, which makes this a better fit for automations that need to react immediately (a local Home Assistant instance, a Raspberry Pi, a chat webhook, or your own service).

This is completely independent of the IFTTT Maker integration described above. Custom webhooks require no `MAKER_KEY` and work whether or not Maker is configured; if you configure both, each delivers on its own and neither affects the other.

Configure up to four destinations using numbered pairs of environment variables. Each destination needs both a URL and the event name it should receive:

* `CUSTOM_WEBHOOK_URL_1` - The full `http://` or `https://` URL to send the notification to. Any other scheme is rejected.
* `CUSTOM_WEBHOOK_EVENT_1` - The Nightscout event name this destination should receive, for example `ns-urgent`.
* `CUSTOM_WEBHOOK_URL_2` / `CUSTOM_WEBHOOK_EVENT_2`, and so on up to `_4`.

For example, to send urgent alarms to your own service and use a second destination as a catch all log:

```
CUSTOM_WEBHOOK_URL_1="https://my-endpoint.example.com/nightscout?token=abc123"
CUSTOM_WEBHOOK_EVENT_1="ns-urgent"
CUSTOM_WEBHOOK_URL_2="http://192.168.1.50:3000/nightscout"
CUSTOM_WEBHOOK_EVENT_2="ns-event"
```

**Event matching.** The event names are the same ones the Maker integration uses, so `ns-event`, `ns-allclear`, `ns-info`, `ns-warning`, `ns-urgent` and the more specific `ns-<level>-<name>` form such as `ns-urgent-low` all work. For each notification Nightscout works out which names apply and delivers to every destination configured for one of them. Unlike Maker, which deliberately sends several events per notification to work around IFTTT's name-only filtering, a custom destination receives **exactly one** request per notification even if more than one of its configured event names matches.

Note that low and high BG alarms use the level qualified form, so a low alarm is `ns-urgent-low` when the `BG_LOW` threshold is crossed and `ns-warning-low` when `BG_TARGET_BOTTOM` is crossed, with `ns-urgent-high` and `ns-warning-high` as the equivalents for high alarms. There is no bare `ns-low` event. If you want a destination to receive every low alarm regardless of severity, configure one entry for each of the two names, or use `ns-event` to receive everything. See the [full list of events](docs/plugins/maker-setup.md#events) for the complete vocabulary.

**Request format.** Each delivery is an HTTP `POST` with a JSON body:

```json
{
"source": "nightscout",
"event": "ns-urgent",
"name": "simplealarms",
"level": "urgent",
"title": "Urgent LOW",
"message": "BG 51",
"isAnnouncement": false,
"mills": 1731000000000,
"iso": "2024-11-07T18:40:00.000Z"
}
```

Notes:

* Numbering does not have to be contiguous; configuring only `_1` and `_3` is fine.
* A pair that is missing either half, or a URL that is not a valid `http`/`https` URL, is skipped with a warning at startup rather than preventing Nightscout from starting.
* Requests time out after 5 seconds and any `2xx` response counts as success. A failing endpoint is logged and never blocks or breaks alarm delivery.
* Because a webhook URL can contain a token, these URLs are treated as secure settings: they are not published in `/api/v1/status`, and logs record only the destination host, never the full URL or the notification contents.
* Anyone who can set these variables can make your Nightscout server issue requests to any address it can reach, including hosts on your private network. Only point them at endpoints you trust.

This feature is separate from the SGV `webhook` plugin, which posts every new glucose reading to a single endpoint rather than routing notification events.


### Treatment Profile
Some of the [plugins](#plugins) make use of a treatment profile that can be edited using the Profile Editor, see the link in the Settings drawer on your site.
Expand Down
8 changes: 8 additions & 0 deletions docs/example-template.env
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,14 @@ PORT=1337
NODE_ENV=development
AUTH_FAIL_DELAY=50

# Custom notification webhooks: deliver Nightscout notifications directly to
# your own http/https endpoint instead of relaying them through IFTTT.
# Each destination needs both a URL and the event name it should receive.
# Numbering does not have to be contiguous, up to 4 pairs are recognized.
# See the "Custom Notification WebHooks" section of the README.
# CUSTOM_WEBHOOK_URL_1="https://my-endpoint.example.com/nightscout"
# CUSTOM_WEBHOOK_EVENT_1="ns-urgent"

# UUID handling for specific client patterns that send UUID as _id field
# Only affects cases where a UUID is sent as the _id field itself
# (e.g., Loop overrides, Trio CGM entries)
Expand Down
1 change: 1 addition & 0 deletions lib/server/bootevent.js
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,7 @@ function boot (env, language) {

ctx.pushover = require('../plugins/pushover')(env, ctx);
ctx.maker = require('../plugins/maker')(env);
ctx.customwebhook = require('./customwebhook')(env);
ctx.pushnotify = require('./pushnotify')(env, ctx);
ctx.loop = require('./loop')(env, ctx);

Expand Down
239 changes: 239 additions & 0 deletions lib/server/customwebhook.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,239 @@
'use strict';

var http = require('http');
var https = require('https');

//how many CUSTOM_WEBHOOK_URL_n / CUSTOM_WEBHOOK_EVENT_n pairs are recognized
var MAX_WEBHOOKS = 4;

var TIMEOUT_MS = 5000;

function init (env) {

var webhooks = findWebhooks(env && env.settings);

var customwebhook = { };

//exposed for testing and for startup logging
customwebhook.webhooks = webhooks;

//The event names a notification can match. These mirror the granularity
//levels produced by makeRequests in lib/plugins/maker.js so operators can
//reuse the event names already documented for the Maker integration.
//Unlike Maker, a matching destination is sent exactly one request.
customwebhook.eventNames = function eventNames (event) {
var names = ['ns-event'];

if (event && event.level) {
names.push('ns-' + event.level);
}

if (event && event.name) {
names.push('ns' + ((event.level && '-' + event.level) || '') + '-' + event.name);
}

return names;
};

customwebhook.sendEvent = function sendEvent (event, callback) {
callback = callback || function noopCallback ( ) { };

if (!event || !event.name) {
callback('No event name found');
} else if (!event.level) {
callback('No event level found');
} else {
deliver(customwebhook.eventNames(event), event, callback);
}
};

customwebhook.sendAllClear = function sendAllClear (notify, callback) {
callback = callback || function noopCallback ( ) { };

deliver(['ns-allclear'], {
name: 'allclear'
, title: (notify && notify.title) || 'All Clear'
, message: notify && notify.message
}, callback);
};

//exposed so tests can replace the outbound request
customwebhook.sendRequest = function sendRequest (webhook, payload, callback) {
var target = webhook.target;
var transport = target.protocol === 'https:' ? https : http;
var body = JSON.stringify(payload);
var finished = false;

//a destroyed socket can emit both 'timeout' and 'error', only report once
function finish (err, response) {
if (finished) { return; }
finished = true;
callback(err, response);
}

var options = {
hostname: target.hostname
, port: target.port || undefined
, path: target.pathname + target.search
, method: 'POST'
, timeout: TIMEOUT_MS
, headers: {
'Content-Type': 'application/json'
, 'Content-Length': Buffer.byteLength(body)
}
};

var request = transport.request(options, function onResponse (response) {
//drain the response so the socket is released
response.on('data', function onData ( ) { });
response.on('end', function onEnd ( ) {
if (response.statusCode >= 200 && response.statusCode < 300) {
finish(null, response);
} else {
finish('unexpected status ' + response.statusCode);
}
});
});

request.on('error', function onError (err) {
finish((err && err.message) || 'request failed');
});

request.on('timeout', function onTimeout ( ) {
//destroy triggers the error handler above, which reports the failure
request.destroy();
});

request.write(body);
request.end();
};

function deliver (names, event, callback) {
var matched = [ ];
//one delivery per destination per notification, so an endpoint configured
//for more than one matching event name is never sent duplicates
var seen = Object.create(null);

webhooks.forEach(function eachWebhook (webhook) {
if (names.indexOf(webhook.event) < 0 || seen[webhook.url]) {
return;
}
seen[webhook.url] = true;
matched.push(webhook);
});

if (matched.length === 0) {
callback(null, {sent: 0});
return;
}

var pending = matched.length;
var errs = [ ];

matched.forEach(function eachMatched (webhook) {
customwebhook.sendRequest(webhook, payloadFor(webhook, event), function requestCallback (err) {
if (err) {
//report the origin only, the full URL may carry a token
errs.push(describe(webhook) + ': ' + err);
}

pending -= 1;
if (pending === 0) {
callback(errs.length > 0 ? errs.join(', ') : null, {
sent: matched.length - errs.length
, matched: matched.length
});
}
});
});
}

function payloadFor (webhook, event) {
var now = Date.now();

return {
source: 'nightscout'
, event: webhook.event
, name: event.name
, level: event.level || null
, title: event.title
, message: event.message
, isAnnouncement: !!event.isAnnouncement
, mills: now
, iso: new Date(now).toISOString()
};
}

if (webhooks.length > 0) {
webhooks.forEach(function eachWebhook (webhook) {
console.info('custom webhook ' + webhook.index + ' listening for ' + webhook.event + ' at ' + describe(webhook));
});
return customwebhook;
} else {
return null;
}

}

//host and port only, never the path or query, which may contain a secret
function describe (webhook) {
return webhook.target.protocol + '//' + webhook.target.host;
}

function findWebhooks (settings) {
var found = [ ];

if (!settings) {
return found;
}

for (var i = 1; i <= MAX_WEBHOOKS; i++) {
var url = trimmed(settings['customWebhookUrl' + i]);
var event = trimmed(settings['customWebhookEvent' + i]);

//an unused slot is normal, indexes do not have to be contiguous
if (!url && !event) {
continue;
}

if (!url || !event) {
console.warn('ignoring custom webhook ' + i + ', both CUSTOM_WEBHOOK_URL_' + i +
' and CUSTOM_WEBHOOK_EVENT_' + i + ' are required');
continue;
}

var target = parseTarget(url);

if (!target) {
console.warn('ignoring custom webhook ' + i + ', CUSTOM_WEBHOOK_URL_' + i +
' is not a valid http or https URL');
continue;
}

found.push({index: i, event: event, url: url, target: target});
}

return found;
}

function parseTarget (url) {
var target;

try {
target = new URL(url);
} catch (err) {
return null;
}

if (target.protocol !== 'http:' && target.protocol !== 'https:') {
return null;
}

return target;
}

function trimmed (value) {
return typeof value === 'string' ? value.trim() : '';
}

module.exports = init;
42 changes: 42 additions & 0 deletions lib/server/pushnotify.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ function init (env, ctx) {
if (notify.clear) {
cancelPushoverNotifications();
sendMakerAllClear(notify);
sendCustomWebhookAllClear(notify);
return;
}

Expand Down Expand Up @@ -51,6 +52,7 @@ function init (env, ctx) {

sendPushoverNotifications(notify);
sendMakerEvent(notify);
sendCustomWebhookEvent(notify);

};

Expand Down Expand Up @@ -142,6 +144,46 @@ function init (env, ctx) {
}
});
}
//Custom webhooks are delivered independently of Maker, they are not gated on
//ctx.maker so they work without a MAKER_KEY, and they do not extend the
//recentlySent TTL, which stays owned by the pushover and Maker paths.
function sendCustomWebhookEvent (notify) {
if (!ctx.customwebhook) {
return;
}

var event = {
name: notify.eventName || notify.plugin.name
, level: levels.toLowerCase(notify.level)
, title: notify.title
, message: notify.message
, isAnnouncement: notify.isAnnouncement
};

ctx.customwebhook.sendEvent(event, function customWebhookCallback (err, result) {
//only the event name and counts are logged, never the notification content
if (err) {
console.error('unable to send custom webhook event ' + event.name + ': ', err);
} else if (result && result.sent > 0) {
console.info('sent custom webhook event: ' + event.name + ', destinations: ' + result.sent);
}
});
}

function sendCustomWebhookAllClear (notify) {
if (!ctx.customwebhook) {
return;
}

ctx.customwebhook.sendAllClear(notify, function customWebhookCallback (err, result) {
if (err) {
console.error('unable to send custom webhook allclear: ', err);
} else if (result && result.sent > 0) {
console.info('sent custom webhook allclear, destinations: ' + result.sent);
}
});
}

function notifyToHash (notify) {
var hash = crypto.createHash('sha1');
var info = JSON.stringify({
Expand Down
Loading