···16161717An [Alerter](https://komo.do/docs/resources#alerter) that pushes to a [Discord Webhook](https://discordjs.guide/popular-topics/webhooks.html#what-is-a-webhook)
18181919-[See README](/notifiers/discord/README.md)1919+[See README](/notifiers/discord/README.md)
2020+2121+## Apprise API Webhook Alerter
2222+2323+An [Alerter](https://komo.do/docs/resources#alerter) that pushes to [Apprise](https://github.com/caronc/apprise) using [Apprise API](https://github.com/caronc/apprise-api)
2424+2525+[See README](/notifiers/apprise/README.md)
+19
notifiers/apprise/Dockerfile
···11+FROM denoland/deno:2.0.4
22+33+# The port that your application listens to.
44+EXPOSE 7000
55+66+WORKDIR /app
77+88+# Prefer not to run as root.
99+USER deno
1010+1111+# These steps will be re-run upon each file change in your working directory:
1212+COPY common/ common/
1313+COPY notifiers/common/ notifiers/common/
1414+COPY notifiers/apprise/ notifiers/apprise/
1515+1616+RUN deno install --entrypoint notifiers/apprise/main.ts
1717+RUN deno cache notifiers/apprise/main.ts
1818+1919+CMD ["run", "--allow-net", "--allow-env", "notifiers/apprise/main.ts"]
+13
notifiers/apprise/README.md
···11+A [Komodo](https://komo.do/) [Alerter](https://komo.do/docs/resources#alerter) client for [Apprise](https://github.com/caronc/apprise) using [Apprise API](https://github.com/caronc/apprise-api)
22+33+# Usage
44+55+See [Komodohub deploy instructions](https://github.com/FoxxMD/deploy-apprise-alerter)
66+77+# Building
88+99+Run from the repository top-level folder:
1010+1111+```shell
1212+docker build -t komodo-apprise-alerter -f notifiers/apprise/Dockerfile .
1313+```
+3
notifiers/apprise/main.ts
···11+import { program } from "./program.ts";
22+33+await program();
+234
notifiers/apprise/program.ts
···11+import { Types } from "npm:komodo_client";
22+import { CommonAlert, parseAlert } from "../common/alertParser.ts";
33+import { alertResolvedAllowed, parseOptions } from "../common/options.ts";
44+import { URLData } from "../common/atomic.ts";
55+import { nonEmptyStringOrDefault, normalizeWebAddress, parseArrayFromMaybeString, truncateStringToLength } from "../common/stringUtils.ts";
66+import { isPortReachable, joinedUrl } from "../common/networkUtils.ts";
77+import { titleAndSubtitle } from "../common/notifierBuilder.ts";
88+99+interface AppriseOptions {
1010+ endpoint: URLData
1111+ urls: string[]
1212+ keys: string[]
1313+ tag?: string
1414+}
1515+1616+const upstreamFailureHint = 'HINT: Status 424 means a dependency upstream of Apprise failed. This is usually a connection or authentication issue. Check Apprise logs to see more details.';
1717+1818+const shortKey = truncateStringToLength(10);
1919+2020+const parseAppriseOptions = async (): Promise<AppriseOptions> => {
2121+2222+ const host = nonEmptyStringOrDefault(Deno.env.get("APPRISE_HOST") as string);
2323+2424+ if(host === undefined) {
2525+ throw new Error(`'APPRISE_HOST' must be defined`);
2626+ }
2727+2828+ const urls: string[] = parseArrayFromMaybeString(nonEmptyStringOrDefault(Deno.env.get("APPRISE_STATELESS_URLS"), ''));
2929+ const keys: string[] = parseArrayFromMaybeString(nonEmptyStringOrDefault(Deno.env.get("APPRISE_PERSISTENT_KEYS"), ''));
3030+3131+ if(urls.length === 0 && keys.length === 0) {
3232+ console.warn(`No 'APPRISE_STATELESS_URLS' or 'APPRISE_PERSISTENT_KEYS' were defined! Will assume stateless (POST ${host}/notify) and that you have the ENV 'APPRISE_STATELESS_URLS' set on your Apprise instance`);
3333+ }
3434+3535+ const tag: string | undefined = nonEmptyStringOrDefault(Deno.env.get("APPRISE_TAG"));
3636+3737+ // check url is correct as a courtesy
3838+ const endpoint = normalizeWebAddress(host);
3939+ console.debug(`Apprise Host Config URL: '${host}' => Normalized: '${endpoint.normal}'`)
4040+4141+ try {
4242+ await isPortReachable(endpoint.port, { host: endpoint.url.hostname });
4343+ } catch (e) {
4444+ console.warn(new Error('Unable to detect if server is reachable', { cause: e }));
4545+ return {endpoint, urls, keys, tag};
4646+ }
4747+4848+ if (keys.length > 0) {
4949+ let anyOk = false;
5050+ for (const key of keys) {
5151+ try {
5252+ const resp = await fetch(joinedUrl(endpoint.url, `/json/urls/${key}`).toString());
5353+ if (resp.status === 204) {
5454+ console.warn(`Details for Config ${shortKey(key)} returned no content. Double check the key is set correctly or that the apprise Config is not empty.`);
5555+ } else {
5656+ anyOk = true;
5757+ }
5858+ } catch (e) {
5959+ console.warn(new Error(`Failed to get details for Config ${shortKey(key)}`, {cause: e}));
6060+ }
6161+ }
6262+ if (!anyOk) {
6363+ console.warn('No Apprise Configs were valid!');
6464+ }
6565+ }
6666+6767+ return {endpoint, urls, keys, tag};
6868+}
6969+7070+const callApi = async (req: Request): Promise<Response> => {
7171+ let resp: Response | undefined;
7272+ try {
7373+ const resp = await fetch(req);
7474+ if(!resp.ok) {
7575+ let text: string;
7676+ try {
7777+ text = await resp.text();
7878+ } catch (e) {
7979+ console.debug(new Error('Could not parse body from response', {cause: e}));
8080+ throw new Error(`Apprise response NOT OK! Status ${resp.status}`);
8181+ }
8282+ let json: object;
8383+ try {
8484+ json = JSON.parse(text);
8585+ } catch (e) {
8686+ throw new Error(`Apprise response NOT OK! Status ${resp.status} | API Response Body => ${text}`);
8787+ }
8888+8989+ if('error' in json) {
9090+ throw new Error(`Apprise response NOT OK! Status ${resp.status} | API Response Error => ${json.error}`);
9191+ } else {
9292+ throw new Error(`Apprise response NOT OK! Status ${resp.status} | API Response Body => ${text}`);
9393+ }
9494+ }
9595+ return resp;
9696+ } catch (e) {
9797+ if(resp !== undefined) {
9898+ console.debug(resp);
9999+ }
100100+ throw e;
101101+ }
102102+}
103103+104104+const program = async () => {
105105+106106+ let appriseOptions: AppriseOptions;
107107+ const commonOpts = parseOptions();
108108+109109+ try {
110110+ appriseOptions = await parseAppriseOptions();
111111+ } catch (e) {
112112+ throw new Error('Could not parse Apprise options', {cause: e});
113113+ }
114114+115115+ const {keys, urls, endpoint, tag} = appriseOptions;
116116+117117+ let configSummary: string[] = [`Using Apprise @ ${endpoint.normal}`];
118118+ if(urls.length === 0 && keys.length === 0) {
119119+ configSummary.push(`Pushing to stateless endpoint (to '/notify')`)
120120+ } else {
121121+ if(urls.length > 0) {
122122+ configSummary.push(`Pushing to stateless URLs '${urls.join(',')}'`);
123123+ }
124124+ if(keys.length > 0) {
125125+ configSummary.push(`Pushing to persistent keys '${keys.join(',')}'`);
126126+ }
127127+ }
128128+ if(tag !== undefined) {
129129+ configSummary.push(`With tag '${tag}'`);
130130+ }
131131+ console.debug(configSummary.join('\n'));
132132+133133+ const pushAlert = async (
134134+ data: CommonAlert,
135135+ level: Types.SeverityLevel,
136136+ ): Promise<any> => {
137137+ let notifyType: string;
138138+ switch (level) {
139139+ case Types.SeverityLevel.Ok:
140140+ notifyType ='info';
141141+ break;
142142+ case Types.SeverityLevel.Warning:
143143+ notifyType = 'warning';
144144+ break;
145145+ case Types.SeverityLevel.Critical:
146146+ notifyType = 'failure';
147147+ break;
148148+ }
149149+150150+ const body: Record<string, any> = {
151151+ title: titleAndSubtitle(data),
152152+ body: data.message,
153153+ type: notifyType
154154+ }
155155+ if(tag !== undefined) {
156156+ body.tag = tag;
157157+ }
158158+159159+ const requestOpts: RequestInit = {
160160+ method: 'POST',
161161+ headers: {
162162+ 'Content-Type': 'application/json',
163163+ 'Accept': 'application/json'
164164+ }
165165+ }
166166+167167+ if(keys.length > 0) {
168168+ for(const k of keys) {
169169+ try {
170170+ const resp = await callApi(new Request(joinedUrl(endpoint.url, `/notify/${k}`).toString(), {
171171+ ...requestOpts,
172172+ body: JSON.stringify(body)
173173+ }));
174174+ await resp.body?.cancel();
175175+ // @ts-expect-error
176176+ } catch (e: Error) {
177177+ console.error(new Error(`Failed to send notification with key ${k}${e.message.includes('Status 424') ? ` | ${upstreamFailureHint}` : ''}`, {cause: e}));
178178+ }
179179+ }
180180+ }
181181+182182+ if(urls.length > 0 || keys.length === 0) {
183183+ const urlBody = {...body};
184184+ if(urls.length > 0) {
185185+ urlBody.urls = urls.join(',');
186186+ }
187187+ try {
188188+ const resp = await callApi(new Request(joinedUrl(endpoint.url, `/notify`).toString(), {
189189+ ...requestOpts,
190190+ body: JSON.stringify(urlBody)
191191+ }));
192192+ await resp.body?.cancel();
193193+ // @ts-expect-error
194194+ } catch (e: Error) {
195195+ console.error(new Error(`Failed to send notification using URLs${e.message.includes('Status 424') ? ` | ${upstreamFailureHint}` : ''}`, {cause: e}));
196196+ }
197197+ }
198198+ };
199199+200200+ const server = Deno.serve({ port: 7000 }, async (req) => {
201201+ const alert: Types.Alert = await req.json();
202202+ console.log(`Recieved data from ${req.headers.get("host")}...`);
203203+204204+ let data: CommonAlert;
205205+206206+ try {
207207+ data = parseAlert(alert, { ...commonOpts });
208208+ } catch (e) {
209209+ console.debug("Komodo Alert Payload:", alert);
210210+ console.error(e);
211211+ return new Response();
212212+ }
213213+214214+ if(!alertResolvedAllowed(commonOpts.allowedResolveTypes, alert.resolved)) {
215215+ console.debug(`Not pushing alert because Alert is ${alert.resolved ? 'resolved' : 'unresolved'} which is not included in allowed resolved types of '${commonOpts.allowedResolveTypes}'`);
216216+ return new Response();
217217+ }
218218+219219+ try {
220220+ await pushAlert(data, alert.level);
221221+ } catch (e) {
222222+ console.debug("Komodo Alert Payload:", alert);
223223+ console.error(
224224+ new Error("Failed to push Alert to Apprise", { cause: e }),
225225+ );
226226+ }
227227+228228+ return new Response();
229229+ });
230230+231231+ return server;
232232+};
233233+234234+export { program };