From 4b24ad38a2f0d8df623db527af5b81c584b5f269 Mon Sep 17 00:00:00 2001 From: Steven Crocker Date: Wed, 15 Oct 2025 07:11:32 -0500 Subject: [PATCH] Add webhook support to notification property in config (#143) * Add basic webhook option to workerConfig.notification as alternative to apprise * Add uptime.config.ts example for webhook option * Removed trailing commas from newly added types for consistency * Add ability to customize webhook body * use template --------- Co-authored-by: lyc8503 --- README.md | 2 +- types/config.ts | 12 ++++- uptime.config.ts | 53 +++++++++++++++------- worker/src/index.ts | 27 +++++------ worker/src/util.ts | 106 ++++++++++++++++++++++++++------------------ 5 files changed, 120 insertions(+), 80 deletions(-) diff --git a/README.md b/README.md index 4083892..0fee041 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ Get the latest features right away with [simple upgrade process](https://github. - [x] Scheduled maintenances (via IIFE) - [ ] Simpler config example - [x] Upcoming maintenances -- [ ] Universal Webhook upgrade +- [x] Universal Webhook upgrade - [ ] i18n...? (maybe) - [ ] ICMP via proxy? - [ ] Add default UA diff --git a/types/config.ts b/types/config.ts index 35f076a..b512642 100644 --- a/types/config.ts +++ b/types/config.ts @@ -54,13 +54,21 @@ export type WorkerConfig = { } export type Notification = { - appriseApiServer?: string - recipientUrl?: string + webhook?: WebhookConfig timeZone?: string gracePeriod?: number skipNotificationIds?: string[] } +export type WebhookConfig = { + url: string + method?: 'GET' | 'POST' | 'PUT' | 'PATCH' + headers?: { [key: string]: string | number } + payloadType: 'param' | 'json' | 'x-www-form-urlencoded' + payload: { [key: string]: string | number } + timeout?: number +} + export type Callbacks = { onStatusChange?: ( env: TEnv, diff --git a/uptime.config.ts b/uptime.config.ts index ae48df2..5263293 100644 --- a/uptime.config.ts +++ b/uptime.config.ts @@ -84,13 +84,34 @@ const workerConfig: WorkerConfig = { timeout: 5000, }, ], + // [Optional] Notification settings notification: { - // [Optional] apprise API server URL - // if not specified, no notification will be sent - appriseApiServer: 'https://apprise.example.com/notify', - // [Optional] recipient URL for apprise, refer to https://github.com/caronc/apprise - // if not specified, no notification will be sent - recipientUrl: 'tgram://bottoken/ChatID', + // [Optional] Notification webhook settings, if not specified, no notification will be sent + // Wiki: TODO + webhook: { + // [Required] webhook URL (example: Telegram Bot API) + url: 'https://api.telegram.org/bot123456:ABCDEF/sendMessage', + // [Optional] HTTP method, default to 'GET' for payloadType=param, 'POST' otherwise + method: 'POST', + // [Optional] headers to be sent + headers: { + foo: 'bar', + }, + // [Required] Specify how to encode the payload + // Should be one of 'param', 'json' or 'x-www-form-urlencoded' + // 'param': append url-encoded payload to URL search parameters + // 'json': POST json payload as body, set content-type header to 'application/json' + // 'x-www-form-urlencoded': POST url-encoded payload as body, set content-type header to 'x-www-form-urlencoded' + payloadType: 'x-www-form-urlencoded', + // [Required] payload to be sent + // $MSG will be replaced with the human-readable notification message + payload: { + chat_id: 12345678, + text: '$MSG', + }, + // [Optional] timeout calling this webhook, in millisecond, default to 5000 + timeout: 10000, + }, // [Optional] timezone used in notification messages, default to "Etc/GMT" timeZone: 'Asia/Shanghai', // [Optional] grace period in minutes before sending a notification @@ -153,15 +174,15 @@ const maintenances: MaintenanceConfig[] = [ // This COULD BE DANGEROUS, as generating too many maintenance entries can lead to performance problems // Undeterministic outputs may also lead to bugs or unexpected behavior // If you don't know how to DEBUG, use this approach WITH CAUTION - ...(function (){ - const schedules = []; - const today = new Date(); + ...(function () { + const schedules = [] + const today = new Date() for (let i = -1; i <= 1; i++) { // JavaScript's Date object will automatically handle year rollovers - const date = new Date(today.getFullYear(), today.getMonth() + i, 15); - const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, '0'); + const date = new Date(today.getFullYear(), today.getMonth() + i, 15) + const year = date.getFullYear() + const month = String(date.getMonth() + 1).padStart(2, '0') schedules.push({ title: `${year}/${parseInt(month)} - Test scheduled maintenance`, @@ -169,11 +190,11 @@ const maintenances: MaintenanceConfig[] = [ body: 'Monthly scheduled maintenance', start: `${year}-${month}-15T02:00:00.000+08:00`, end: `${year}-${month}-15T04:00:00.000+08:00`, - }); + }) } - return schedules; - })() + return schedules + })(), ] // Don't forget this, otherwise compilation fails. -export { pageConfig, workerConfig, maintenances } +export { maintenances, pageConfig, workerConfig } diff --git a/worker/src/index.ts b/worker/src/index.ts index 0d9d31e..ab62bb9 100644 --- a/worker/src/index.ts +++ b/worker/src/index.ts @@ -1,8 +1,8 @@ -import { workerConfig, maintenances } from '../../uptime.config' -import { formatStatusChangeNotification, getWorkerLocation, notifyWithApprise } from './util' -import { MonitorState, MonitorTarget } from '../../types/config' -import { getStatus } from './monitor' import { DurableObject } from 'cloudflare:workers' +import { MonitorState, MonitorTarget } from '../../types/config' +import { maintenances, workerConfig } from '../../uptime.config' +import { getStatus } from './monitor' +import { formatStatusChangeNotification, getWorkerLocation, webhookNotify } from './util' export interface Env { UPTIMEFLARE_STATE: KVNamespace @@ -14,7 +14,7 @@ const Worker = { const workerLocation = (await getWorkerLocation()) || 'ERROR' console.log(`Running scheduled event on ${workerLocation}...`) - // Auxiliary function to format notification and send it via apprise + // Auxiliary function to format notification and send it via webhook let formatAndNotify = async ( monitor: MonitorTarget, isUp: boolean, @@ -46,7 +46,7 @@ const Worker = { return } - if (workerConfig.notification?.appriseApiServer && workerConfig.notification?.recipientUrl) { + if (workerConfig.notification?.webhook) { const notification = formatStatusChangeNotification( monitor, isUp, @@ -55,16 +55,9 @@ const Worker = { reason, workerConfig.notification?.timeZone ?? 'Etc/GMT' ) - await notifyWithApprise( - workerConfig.notification.appriseApiServer, - workerConfig.notification.recipientUrl, - notification.title, - notification.body - ) + await webhookNotify(workerConfig.notification.webhook, notification) } else { - console.log( - `Apprise API server or recipient URL not set, skipping apprise notification for ${monitor.name}` - ) + console.log(`Webhook not set, skipping notification for ${monitor.name}`) } } @@ -174,7 +167,7 @@ const Worker = { await formatAndNotify(monitor, true, lastIncident.start[0], currentTimeSecond, 'OK') } else { console.log( - `grace period (${workerConfig.notification?.gracePeriod}m) not met, skipping apprise UP notification for ${monitor.name}` + `grace period (${workerConfig.notification?.gracePeriod}m) not met, skipping webhook UP notification for ${monitor.name}` ) } @@ -242,7 +235,7 @@ const Worker = { `Grace period (${workerConfig.notification ?.gracePeriod}m) not met (currently down for ${ currentTimeSecond - currentIncident.start[0] - }s, changed ${monitorStatusChanged}), skipping apprise DOWN notification for ${ + }s, changed ${monitorStatusChanged}), skipping webhook DOWN notification for ${ monitor.name }` ) diff --git a/worker/src/util.ts b/worker/src/util.ts index 2bf77f2..bf0b5ad 100644 --- a/worker/src/util.ts +++ b/worker/src/util.ts @@ -1,3 +1,5 @@ +import { WebhookConfig } from '../../types/config' + async function getWorkerLocation() { const res = await fetch('https://cloudflare.com/cdn-cgi/trace') const text = await res.text() @@ -48,65 +50,81 @@ function formatStatusChangeNotification( const timeIncidentStartFormatted = dateFormatter.format(new Date(timeIncidentStart * 1000)) if (isUp) { - return { - title: `✅ ${monitor.name} is up!`, - body: `The service is up again after being down for ${downtimeDuration} minutes.`, - } + return `✅ ${monitor.name} is up! \nThe service is up again after being down for ${downtimeDuration} minutes.` } else if (timeNow == timeIncidentStart) { - return { - title: `🔴 ${monitor.name} is currently down.`, - body: `Service is unavailable at ${timeNowFormatted}. Issue: ${reason || 'unspecified'}`, - } + return `🔴 ${ + monitor.name + } is currently down. \nService is unavailable at ${timeNowFormatted}. \nIssue: ${ + reason || 'unspecified' + }` } else { - return { - title: `🔴 ${monitor.name} is still down.`, - body: `Service is unavailable since ${timeIncidentStartFormatted} (${downtimeDuration} minutes). Issue: ${ - reason || 'unspecified' - }`, - } + return `🔴 ${ + monitor.name + } is still down. \nService is unavailable since ${timeIncidentStartFormatted} (${downtimeDuration} minutes). \nIssue: ${ + reason || 'unspecified' + }` } } -async function notifyWithApprise( - appriseApiServer: string, - recipientUrl: string, - title: string, - body: string -) { +async function webhookNotify(webhook: WebhookConfig, message: string) { console.log( - 'Sending Apprise notification: ' + - title + - '-' + - body + - ' to ' + - recipientUrl + - ' via ' + - appriseApiServer + 'Sending webhook notification: ' + JSON.stringify(message) + ' to webhook ' + webhook.url ) try { - const resp = await fetchTimeout(appriseApiServer, 5000, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - urls: recipientUrl, - title, - body, - type: 'warning', - format: 'text', - }), + let url = webhook.url + let method = webhook.method + let headers = new Headers(webhook.headers as any) + let payloadTemplated = webhook.payload + Object.keys(payloadTemplated).forEach((k) => { + if (payloadTemplated[k] === '$MSG') { + payloadTemplated[k] = message + } }) + let body = undefined + + switch (webhook.payloadType) { + case 'param': + method = method ?? 'GET' + const urlTmp = new URL(url) + for (const [k, v] of Object.entries(payloadTemplated)) { + urlTmp.searchParams.append(k, v.toString()) + } + url = urlTmp.toString() + break + case 'json': + method = method ?? 'POST' + if (headers.get('content-type') === null) { + headers.set('content-type', 'application/json') + } + body = JSON.stringify(payloadTemplated) + break + case 'x-www-form-urlencoded': + method = method ?? 'POST' + if (headers.get('content-type') === null) { + headers.set('content-type', 'application/x-www-form-urlencoded') + } + body = new URLSearchParams(payloadTemplated as any).toString() + break + default: + throw 'Unrecognized payload type: ' + webhook.payloadType + } + + console.log( + `Webhook finalized parameters: ${method} ${url}, headers ${JSON.stringify( + Object.fromEntries(headers.entries()) + )}, body ${JSON.stringify(body)}` + ) + const resp = await fetchTimeout(url, webhook.timeout ?? 5000, { method, headers, body }) if (!resp.ok) { console.log( - 'Error calling apprise server, code: ' + resp.status + ', response: ' + (await resp.text()) + 'Error calling webhook server, code: ' + resp.status + ', response: ' + (await resp.text()) ) } else { - console.log('Apprise notification sent successfully, code: ' + resp.status) + console.log('Webhook notification sent successfully, code: ' + resp.status) } } catch (e) { - console.log('Error calling apprise server: ' + e) + console.log('Error calling webhook server: ' + e) } } @@ -114,6 +132,6 @@ export { getWorkerLocation, fetchTimeout, withTimeout, - notifyWithApprise, + webhookNotify, formatStatusChangeNotification, }