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
parent
d11f8deeb2
commit
4b24ad38a2
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}`
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue