[READ-ONLY] Mirror of https://github.com/FoxxMD/komodo-utilities. Small utilities to enhance Komodo
gotify komodo
0

Configure Feed

Select the types of activity you want to include in your feed.

feat: Implement Discord Webhook alerter

FoxxMD (Dec 13, 2024, 3:59 PM UTC) 1cf15621 9a753c84

+272 -19
+33 -1
.vscode/launch.json
··· 35 35 "--allow-env" 36 36 ], 37 37 "attachSimplePort": 9229 38 - } 38 + }, 39 + { 40 + "request": "launch", 41 + "name": "Launch Discord Alerter", 42 + "type": "node", 43 + "program": "${workspaceFolder}/notifiers/discord/main.ts", 44 + "cwd": "${workspaceFolder}", 45 + "envFile": "${workspaceFolder}/.env", 46 + "runtimeExecutable": "/usr/local/bin/deno", 47 + "runtimeArgs": [ 48 + "run", 49 + "--inspect-wait", 50 + "--allow-net", 51 + "--allow-env" 52 + ], 53 + "attachSimplePort": 9229 54 + }, 55 + { 56 + "request": "launch", 57 + "name": "Debug Test File", 58 + "type": "node", 59 + "program": "${file}", 60 + "cwd": "${workspaceFolder}", 61 + "envFile": "${workspaceFolder}/.env", 62 + "runtimeExecutable": "/usr/local/bin/deno", 63 + "runtimeArgs": [ 64 + "test", 65 + "--inspect-wait", 66 + "--allow-net", 67 + "--allow-env" 68 + ], 69 + "attachSimplePort": 9229 70 + }, 39 71 ] 40 72 }
+7 -1
README.md
··· 10 10 11 11 An [Alerter](https://komo.do/docs/resources#alerter) that pushes to [ntfy](https://ntfy.sh/) 12 12 13 - [See README](/notifiers/ntfy/README.md) 13 + [See README](/notifiers/ntfy/README.md) 14 + 15 + ## Discord Webhook Alerter 16 + 17 + An [Alerter](https://komo.do/docs/resources#alerter) that pushes to a [Discord Webhook](https://discordjs.guide/popular-topics/webhooks.html#what-is-a-webhook) 18 + 19 + [See README](/notifiers/discord/README.md)
+55 -17
notifiers/common/alertParser.ts
··· 1 1 import { Types } from "npm:komodo_client"; 2 2 import { formatNumber } from "../../common/utils.ts"; 3 + import { italic } from "../../../../home/node/.cache/deno/npm/registry.npmjs.org/@discordeno/utils/19.0.0/dist/types/colors.d.ts"; 3 4 4 5 export interface CommonAlert { 5 6 title: string; ··· 7 8 message: string | undefined; 8 9 } 9 10 11 + export interface RichAlert extends CommonAlert { 12 + } 13 + 10 14 export interface ParsingOptions { 11 15 levelInTitle?: boolean; 16 + markdown?: boolean 12 17 } 13 18 14 - export const parseAlert = ( 19 + type Alert = CommonAlert | RichAlert; 20 + 21 + type FormatFunc = (str: string) => string; 22 + type Formatting = { 23 + bold: FormatFunc, 24 + italic: FormatFunc 25 + codeInline: FormatFunc 26 + codeBlock: FormatFunc 27 + } 28 + const noFormatting: FormatFunc = (str: string) => str; 29 + 30 + const formatting = (enabled: boolean): Formatting => { 31 + if(!enabled) { 32 + return { 33 + bold: noFormatting, 34 + italic: noFormatting, 35 + codeInline: noFormatting, 36 + codeBlock: noFormatting 37 + } 38 + } 39 + return { 40 + bold: (str: string) => `**${str}**`, 41 + italic: (str: string) => `_${str}**`, 42 + codeInline: (str: string) => `\`${str}\``, 43 + codeBlock: (str: string) => `\`\`\` 44 + ${str} 45 + \`\`\`` 46 + } 47 + } 48 + 49 + export const parseAlert = <T extends Alert = CommonAlert>( 15 50 alert: Types.Alert, 16 51 options: ParsingOptions = {}, 17 - ): CommonAlert => { 52 + ): T => { 18 53 const { 19 54 levelInTitle = true, 55 + markdown = false, 20 56 } = options; 21 57 22 58 const { ··· 26 62 } = {}, 27 63 } = alert; 28 64 65 + const formatter = formatting(markdown); 66 + 29 67 const message: string[] = []; 30 68 const title: string[] = []; 31 69 const subtitle: string[] = []; ··· 42 80 43 81 if (data !== undefined) { 44 82 if ("name" in data) { 45 - subtitle.push(`for ${data.name}`); 83 + subtitle.push(`for ${formatter.bold(data.name)}`); 46 84 } 47 85 if ("server_name" in data) { 48 - subtitle.push(`on ${data.server_name}`); 86 + subtitle.push(`on ${formatter.bold(data.server_name)}`); 49 87 } 50 88 51 89 switch (type) { 52 90 case "ServerCpu": 53 91 message.push( 54 - `Hit ${formatNumber(data.percentage, { max: 0 })}%`, 92 + `Hit ${formatter.bold(`${formatNumber(data.percentage, { max: 0 })}%`)}`, 55 93 ); 56 94 break; 57 95 case "ServerMem": 58 96 message.push( 59 - `Used ${formatNumber(data.used_gb)}/${ 97 + `Used ${formatter.bold(`${formatNumber(data.used_gb)}/${ 60 98 formatNumber(data.total_gb) 61 - }GB`, 99 + }GB`)}`, 62 100 ); 63 101 break; 64 102 case "ServerDisk": 65 103 message.push( 66 - `Disk at ${data.path} used ${ 104 + `Disk at ${formatter.bold(data.path)} used ${formatter.bold(`${ 67 105 formatNumber(data.used_gb) 68 - }/${formatNumber(data.total_gb)}GB`, 106 + }/${formatNumber(data.total_gb)}GB`)}`, 69 107 ); 70 108 break; 71 109 case "StackImageUpdateAvailable": 72 110 message.push( 73 - `Service ${data.service} | Image ${data.image}`, 111 + `Service ${formatter.bold(data.service)} | Image ${formatter.bold(data.image)}`, 74 112 ); 75 113 break; 76 114 case "DeploymentImageUpdateAvailable": 77 - message.push(`Image ${data.image}`); 115 + message.push(`Image ${formatter.bold(data.image)}`); 78 116 break; 79 117 case "AwsBuilderTerminationFailed": 80 118 message.push( 81 - `Instance ${data.instance_id} | Reason: ${data.message}`, 119 + `Instance ${formatter.bold(data.instance_id)} | Reason: ${formatter.bold(data.message)}`, 82 120 ); 83 121 break; 84 122 case "None": 85 123 break; 86 124 default: 87 125 if ("err" in data && data.err !== undefined) { 88 - message.push(`Err: ${data.err.error}`); 126 + message.push(`Err: ${formatter.codeBlock(data.err.error)}`); 89 127 } 90 128 if ("from" in data) { 91 - message.push(`From ${data.from}`); 129 + message.push(`From ${formatter.bold(data.from)}`); 92 130 } 93 131 if ("to" in data) { 94 - message.push(`To ${data.to}`); 132 + message.push(`To ${formatter.bold(data.to)}`); 95 133 } 96 134 if ("version" in data) { 97 135 message.push( 98 - `Version ${data.version.major}.${data.version.minor}.${data.version.patch}`, 136 + `Version ${formatter.bold(`${data.version.major}.${data.version.minor}.${data.version.patch}`)}`, 99 137 ); 100 138 } 101 139 break; ··· 106 144 title: title.join(" "), 107 145 subtitle: subtitle.length > 0 ? subtitle.join(" ") : undefined, 108 146 message: message.length > 0 ? message.join(" ") : undefined, 109 - }; 147 + } as T; 110 148 } catch (e) { 111 149 throw new Error("Error occurred while trying to parse Alert data", { 112 150 cause: e,
+16
notifiers/discord/Dockerfile
··· 1 + FROM denoland/deno:2.0.4 2 + 3 + # The port that your application listens to. 4 + EXPOSE 7000 5 + 6 + WORKDIR /app 7 + 8 + # Prefer not to run as root. 9 + USER deno 10 + 11 + # These steps will be re-run upon each file change in your working directory: 12 + COPY common/ common/ 13 + COPY notifiers/common/ notifiers/common/ 14 + COPY notifiers/discord/ notifiers/discord/ 15 + 16 + CMD ["run", "--allow-net", "--allow-env", "notifiers/discord/main.ts"]
+13
notifiers/discord/README.md
··· 1 + A [Komodo](https://komo.do/) [Alerter](https://komo.do/docs/resources#alerter) client for [Discord Webhooks](https://discordjs.guide/popular-topics/webhooks.html#what-is-a-webhook) 2 + 3 + # Usage 4 + 5 + See [Komodohub deploy instructions](https://github.com/FoxxMD/deploy-discord-alerter) 6 + 7 + # Building 8 + 9 + Run from the repository top-level folder: 10 + 11 + ```shell 12 + docker build -t komodo-discord-alerter -f notifiers/discord/Dockerfile . 13 + ```
+3
notifiers/discord/main.ts
··· 1 + import {program} from './program.ts'; 2 + 3 + program();
+102
notifiers/discord/program.ts
··· 1 + import { Types } from "npm:komodo_client"; 2 + import { EmbedBuilder, WebhookClient } from 'npm:discord.js'; 3 + import { CommonAlert, parseAlert } from "../common/alertParser.ts"; 4 + import { valToBoolean } from "../../common/utils.ts"; 5 + import path from 'node:path'; 6 + 7 + const program = () => { 8 + 9 + const DISCORD_WEBHOOK: string = Deno.env.get("DISCORD_WEBHOOK") as string; 10 + if (DISCORD_WEBHOOK === undefined || DISCORD_WEBHOOK.trim() === "") { 11 + console.error("DISCORD_WEBHOOK not defined in ENV"); 12 + Deno.exit(1); 13 + } 14 + 15 + console.log(`Webhook : ${DISCORD_WEBHOOK}`); 16 + 17 + const hookParts = path.parse(DISCORD_WEBHOOK); 18 + const token = hookParts.name; 19 + const id = hookParts.dir.split('/').pop(); 20 + 21 + if(id === undefined) { 22 + console.error("Could not find ID in DISCORD_WEBHOOK. Is the webhook URL properly formed? https://discordjs.guide/popular-topics/webhooks.html#creating-webhooks-through-server-settings"); 23 + Deno.exit(1); 24 + } 25 + console.info(`Webhook ID : ${id}`); 26 + if(token === undefined) { 27 + console.error("Could not find token in DISCORD_WEBHOOK. Is the webhook URL properly formed? https://discordjs.guide/popular-topics/webhooks.html#creating-webhooks-through-server-settings"); 28 + Deno.exit(1); 29 + } 30 + console.info(`Webhook Token: ${token}`); 31 + 32 + let levelInTitle: boolean | undefined; 33 + try { 34 + levelInTitle = valToBoolean(Deno.env.get('LEVEL_IN_TITLE')); 35 + } catch (e) { 36 + console.warn('Could not parse LEVEL_IN_TITLE to a truthy value, will use notifier default', {cause: e}); 37 + } 38 + 39 + 40 + const pushAlert = async (data: CommonAlert, level: Types.SeverityLevel): Promise<any> => { 41 + const embed = new EmbedBuilder(); 42 + switch(level) { 43 + case Types.SeverityLevel.Ok: 44 + embed.setColor('#58b9ff'); 45 + break; 46 + case Types.SeverityLevel.Warning: 47 + embed.setColor('#fa8020'); 48 + break; 49 + case Types.SeverityLevel.Critical: 50 + embed.setColor('#fa2020'); 51 + break; 52 + } 53 + 54 + embed.setTitle(data.title); 55 + if(data.subtitle !== undefined) { 56 + embed.setDescription(data.subtitle); 57 + } 58 + if(data.message !== undefined) { 59 + embed.addFields({name: 'Message', value: data.message}); 60 + } 61 + 62 + const client = new WebhookClient({id, token}); 63 + try { 64 + const msg = await client.send({ 65 + embeds: [embed] 66 + }); 67 + } catch (e) { 68 + throw new Error('Failed to send Discord Webhook', {cause: e}) 69 + } 70 + } 71 + 72 + const server = Deno.serve({ port: 7000 }, async (req) => { 73 + const alert: Types.Alert = await req.json(); 74 + console.log(`Recieved data from ${req.headers.get("host")}...`); 75 + 76 + let data: CommonAlert; 77 + 78 + try { 79 + data = parseAlert(alert, {levelInTitle, markdown: true}); 80 + } catch (e) { 81 + console.debug('Komodo Alert Payload:', alert); 82 + console.error(e); 83 + return new Response(); 84 + } 85 + 86 + try { 87 + await pushAlert(data, alert.level) 88 + } catch (e) { 89 + console.debug('Komodo Alert Payload:', alert); 90 + console.error( 91 + new Error("Failed to push Alert to Gotify", { cause: e }), 92 + ); 93 + } 94 + 95 + return new Response(); 96 + }); 97 + 98 + return server; 99 + 100 + } 101 + 102 + export {program};
+43
notifiers/runners/discord.runner.ts
··· 1 + import { expect } from "jsr:@std/expect"; 2 + import { ServerMem, StackImageUpdateAvailable } from "../tests/fixtures.ts"; 3 + import { program } from "../discord/program.ts"; 4 + 5 + Deno.test({ 6 + name: "Discord - run memory alert", 7 + async fn() { 8 + const server = program(); 9 + try { 10 + 11 + const req = new Request("http://127.0.0.1:7000", { 12 + method: "POST", 13 + body: JSON.stringify(ServerMem), 14 + }); 15 + const resp = await fetch(req); 16 + expect(resp.ok).toBeTruthy(); 17 + } catch (e) { 18 + throw e; 19 + } finally { 20 + await server.shutdown(); 21 + } 22 + }, 23 + }); 24 + 25 + Deno.test({ 26 + name: "Discord - stack image update", 27 + async fn() { 28 + const server = program(); 29 + try { 30 + 31 + const req = new Request("http://127.0.0.1:7000", { 32 + method: "POST", 33 + body: JSON.stringify(StackImageUpdateAvailable), 34 + }); 35 + const resp = await fetch(req); 36 + expect(resp.ok).toBeTruthy(); 37 + } catch (e) { 38 + throw e; 39 + } finally { 40 + await server.shutdown(); 41 + } 42 + }, 43 + });