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 <me@lyc8503.net>
pull/153/head
Steven Crocker 2025-10-15 07:11:32 -05:00 committed by GitHub
parent d11f8deeb2
commit 4b24ad38a2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 120 additions and 80 deletions

View File

@ -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

View File

@ -54,13 +54,21 @@ export type WorkerConfig<TEnv = Env> = {
}
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<TEnv = Env> = {
onStatusChange?: (
env: TEnv,

View File

@ -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 }

View File

@ -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
}`
)

View File

@ -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,
}