feat: add ntfy integration (#2900)

Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
This commit is contained in:
Meow
2025-06-23 11:40:49 -06:00
committed by GitHub
parent 95be0391a6
commit e110a84fdd
20 changed files with 349 additions and 8 deletions

View File

@@ -1,4 +1,4 @@
import { IconGrid3x3, IconKey, IconPassword, IconServer, IconUser } from "@tabler/icons-react";
import { IconGrid3x3, IconKey, IconMessage, IconPassword, IconServer, IconUser } from "@tabler/icons-react";
import type { IntegrationSecretKind } from "@homarr/definitions";
import type { TablerIcon } from "@homarr/ui";
@@ -9,4 +9,5 @@ export const integrationSecretIcons = {
password: IconPassword,
realm: IconServer,
tokenId: IconGrid3x3,
topic: IconMessage,
} satisfies Record<IntegrationSecretKind, TablerIcon>;

View File

@@ -11,6 +11,7 @@ import { mediaTranscodingRouter } from "./media-transcoding";
import { minecraftRouter } from "./minecraft";
import { networkControllerRouter } from "./network-controller";
import { notebookRouter } from "./notebook";
import { notificationsRouter } from "./notifications";
import { optionsRouter } from "./options";
import { releasesRouter } from "./releases";
import { rssFeedRouter } from "./rssFeed";
@@ -37,4 +38,5 @@ export const widgetRouter = createTRPCRouter({
options: optionsRouter,
releases: releasesRouter,
networkController: networkControllerRouter,
notifications: notificationsRouter,
});

View File

@@ -0,0 +1,64 @@
import { observable } from "@trpc/server/observable";
import type { Modify } from "@homarr/common/types";
import type { Integration } from "@homarr/db/schema";
import type { IntegrationKindByCategory } from "@homarr/definitions";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import type { Notification } from "@homarr/integrations";
import { notificationsRequestHandler } from "@homarr/request-handler/notifications";
import type { IntegrationAction } from "../../middlewares/integration";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
const createNotificationsIntegrationMiddleware = (action: IntegrationAction) =>
createManyIntegrationMiddleware(action, ...getIntegrationKindsByCategory("notifications"));
export const notificationsRouter = createTRPCRouter({
getNotifications: publicProcedure
.unstable_concat(createNotificationsIntegrationMiddleware("query"))
.query(async ({ ctx }) => {
return await Promise.all(
ctx.integrations.map(async (integration) => {
const innerHandler = notificationsRequestHandler.handler(integration, {});
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return {
integration: {
id: integration.id,
name: integration.name,
kind: integration.kind,
updatedAt: timestamp,
},
data,
};
}),
);
}),
subscribeNotifications: publicProcedure
.unstable_concat(createNotificationsIntegrationMiddleware("query"))
.subscription(({ ctx }) => {
return observable<{
integration: Modify<Integration, { kind: IntegrationKindByCategory<"notifications"> }>;
data: Notification[];
}>((emit) => {
const unsubscribes: (() => void)[] = [];
for (const integrationWithSecrets of ctx.integrations) {
const { decryptedSecrets: _, ...integration } = integrationWithSecrets;
const innerHandler = notificationsRequestHandler.handler(integrationWithSecrets, {});
const unsubscribe = innerHandler.subscribe((data) => {
emit.next({
integration,
data,
});
});
unsubscribes.push(unsubscribe);
}
return () => {
unsubscribes.forEach((unsubscribe) => {
unsubscribe();
});
};
});
}),
});

View File

@@ -10,11 +10,11 @@ const calculateTimeAgo = (timestamp: Date) => {
return dayjs().to(timestamp);
};
export const useTimeAgo = (timestamp: Date) => {
export const useTimeAgo = (timestamp: Date, updateFrequency = 1000) => {
const [timeAgo, setTimeAgo] = useState(calculateTimeAgo(timestamp));
useEffect(() => {
const intervalId = setInterval(() => setTimeAgo(calculateTimeAgo(timestamp)), 1000); // update every second
const intervalId = setInterval(() => setTimeAgo(calculateTimeAgo(timestamp)), updateFrequency);
return () => clearInterval(intervalId); // clear interval on hook unmount
}, [timestamp]);

View File

@@ -25,6 +25,7 @@ export const cronJobs = {
minecraftServerStatus: { preventManualExecution: false },
networkController: { preventManualExecution: false },
dockerContainers: { preventManualExecution: false },
refreshNotifications: { preventManualExecution: false },
} satisfies Record<JobGroupKeys, { preventManualExecution?: boolean }>;
/**

View File

@@ -11,6 +11,7 @@ import { mediaRequestListJob, mediaRequestStatsJob } from "./jobs/integrations/m
import { mediaServerJob } from "./jobs/integrations/media-server";
import { mediaTranscodingJob } from "./jobs/integrations/media-transcoding";
import { networkControllerJob } from "./jobs/integrations/network-controller";
import { refreshNotificationsJob } from "./jobs/integrations/notifications";
import { minecraftServerStatusJob } from "./jobs/minecraft-server-status";
import { pingJob } from "./jobs/ping";
import { rssFeedsJob } from "./jobs/rss-feeds";
@@ -38,6 +39,7 @@ export const jobGroup = createCronJobGroup({
minecraftServerStatus: minecraftServerStatusJob,
dockerContainers: dockerContainersJob,
networkController: networkControllerJob,
refreshNotifications: refreshNotificationsJob,
});
export type JobGroupKeys = ReturnType<(typeof jobGroup)["getKeys"]>[number];

View File

@@ -0,0 +1,14 @@
import { EVERY_5_MINUTES } from "@homarr/cron-jobs-core/expressions";
import { createRequestIntegrationJobHandler } from "@homarr/request-handler/lib/cached-request-integration-job-handler";
import { notificationsRequestHandler } from "@homarr/request-handler/notifications";
import { createCronJob } from "../../lib";
export const refreshNotificationsJob = createCronJob("refreshNotifications", EVERY_5_MINUTES).withCallback(
createRequestIntegrationJobHandler(notificationsRequestHandler.handler, {
widgetKinds: ["notifications"],
getInput: {
notifications: (options) => options,
},
}),
);

View File

@@ -7,6 +7,7 @@ export const integrationSecretKindObject = {
password: { isPublic: false },
tokenId: { isPublic: true },
realm: { isPublic: true },
topic: { isPublic: true },
} satisfies Record<string, { isPublic: boolean }>;
export const integrationSecretKinds = objectKeys(integrationSecretKindObject);
@@ -169,6 +170,12 @@ export const integrationDefs = {
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/unifi.png",
category: ["networkController"],
},
ntfy: {
name: "ntfy",
secretKinds: [["topic"], ["topic", "apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/ntfy.svg",
category: ["notifications"],
},
} as const satisfies Record<string, integrationDefinition>;
export const integrationKinds = objectKeys(integrationDefs) as AtLeastOneOf<IntegrationKind>;
@@ -223,4 +230,5 @@ export type IntegrationCategory =
| "healthMonitoring"
| "search"
| "mediaTranscoding"
| "networkController";
| "networkController"
| "notifications";

View File

@@ -25,5 +25,6 @@ export const widgetKinds = [
"healthMonitoring",
"releases",
"dockerContainers",
"notifications",
] as const;
export type WidgetKind = (typeof widgetKinds)[number];

View File

@@ -21,6 +21,7 @@ import { ReadarrIntegration } from "../media-organizer/readarr/readarr-integrati
import { SonarrIntegration } from "../media-organizer/sonarr/sonarr-integration";
import { TdarrIntegration } from "../media-transcoding/tdarr-integration";
import { NextcloudIntegration } from "../nextcloud/nextcloud.integration";
import { NTFYIntegration } from "../ntfy/ntfy-integration";
import { OpenMediaVaultIntegration } from "../openmediavault/openmediavault-integration";
import { OverseerrIntegration } from "../overseerr/overseerr-integration";
import { createPiHoleIntegrationAsync } from "../pi-hole/pi-hole-integration-factory";
@@ -92,6 +93,7 @@ export const integrationCreators = {
emby: EmbyIntegration,
nextcloud: NextcloudIntegration,
unifiController: UnifiControllerIntegration,
ntfy: NTFYIntegration,
} satisfies Record<IntegrationKind, IntegrationInstance | [(input: IntegrationInput) => Promise<Integration>]>;
type IntegrationInstanceOfKind<TKind extends keyof typeof integrationCreators> = {

View File

@@ -1,26 +1,27 @@
// General integrations
export { AdGuardHomeIntegration } from "./adguard-home/adguard-home-integration";
export { Aria2Integration } from "./download-client/aria2/aria2-integration";
export { DelugeIntegration } from "./download-client/deluge/deluge-integration";
export { NzbGetIntegration } from "./download-client/nzbget/nzbget-integration";
export { QBitTorrentIntegration } from "./download-client/qbittorrent/qbittorrent-integration";
export { SabnzbdIntegration } from "./download-client/sabnzbd/sabnzbd-integration";
export { TransmissionIntegration } from "./download-client/transmission/transmission-integration";
export { Aria2Integration } from "./download-client/aria2/aria2-integration";
export { HomeAssistantIntegration } from "./homeassistant/homeassistant-integration";
export { DownloadClientIntegration } from "./interfaces/downloads/download-client-integration";
export { JellyfinIntegration } from "./jellyfin/jellyfin-integration";
export { JellyseerrIntegration } from "./jellyseerr/jellyseerr-integration";
export { LidarrIntegration } from "./media-organizer/lidarr/lidarr-integration";
export { RadarrIntegration } from "./media-organizer/radarr/radarr-integration";
export { ReadarrIntegration } from "./media-organizer/readarr/readarr-integration";
export { SonarrIntegration } from "./media-organizer/sonarr/sonarr-integration";
export { NextcloudIntegration } from "./nextcloud/nextcloud.integration";
export { NTFYIntegration } from "./ntfy/ntfy-integration";
export { OpenMediaVaultIntegration } from "./openmediavault/openmediavault-integration";
export { OverseerrIntegration } from "./overseerr/overseerr-integration";
export { PiHoleIntegrationV5 } from "./pi-hole/v5/pi-hole-integration-v5";
export { PiHoleIntegrationV6 } from "./pi-hole/v6/pi-hole-integration-v6";
export { PlexIntegration } from "./plex/plex-integration";
export { ProwlarrIntegration } from "./prowlarr/prowlarr-integration";
export { LidarrIntegration } from "./media-organizer/lidarr/lidarr-integration";
export { ReadarrIntegration } from "./media-organizer/readarr/readarr-integration";
export { NextcloudIntegration } from "./nextcloud/nextcloud.integration";
// Types
export type { IntegrationInput } from "./base/integration";
@@ -34,6 +35,7 @@ export type { StreamSession } from "./interfaces/media-server/session";
export type { TdarrQueue } from "./interfaces/media-transcoding/queue";
export type { TdarrPieSegment, TdarrStatistics } from "./interfaces/media-transcoding/statistics";
export type { TdarrWorker } from "./interfaces/media-transcoding/workers";
export type { Notification } from "./interfaces/notifications/notification";
// Schemas
export { downloadClientItemSchema } from "./interfaces/downloads/download-client-items";

View File

@@ -0,0 +1,6 @@
export interface Notification {
id: string;
time: Date;
title: string;
body: string;
}

View File

@@ -0,0 +1,6 @@
import { Integration } from "../../base/integration";
import type { Notification } from "./notification";
export abstract class NotificationsIntegration extends Integration {
public abstract getNotificationsAsync(): Promise<Notification[]>;
}

View File

@@ -0,0 +1,65 @@
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import { ResponseError } from "@homarr/common/server";
import type { IntegrationTestingInput } from "../base/integration";
import type { TestingResult } from "../base/test-connection/test-connection-service";
import type { Notification } from "../interfaces/notifications/notification";
import { NotificationsIntegration } from "../interfaces/notifications/notifications-integration";
import { ntfyNotificationSchema } from "./ntfy-schema";
export class NTFYIntegration extends NotificationsIntegration {
public async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
await input.fetchAsync(this.url("/v1/account"), { headers: this.getHeaders() });
return { success: true };
}
private getTopicURL() {
return this.url(`/${encodeURIComponent(super.getSecretValue("topic"))}/json`, { poll: 1 });
}
private getHeaders() {
return this.hasSecretValue("apiKey") ? { Authorization: `Bearer ${super.getSecretValue("apiKey")}` } : {};
}
public async getNotificationsAsync() {
const url = this.getTopicURL();
const notifications = await Promise.all(
(
await fetchWithTrustedCertificatesAsync(url, { headers: this.getHeaders() })
.then((response) => {
if (!response.ok) throw new ResponseError(response);
return response.text();
})
.catch((error) => {
if (error instanceof Error) throw error;
else {
throw new Error("Error communicating with ntfy");
}
})
)
// response is provided as individual lines of JSON
.split("\n")
.map(async (line) => {
// ignore empty lines
if (line.length === 0) return null;
const json = JSON.parse(line) as unknown;
const parsed = await ntfyNotificationSchema.parseAsync(json);
if (parsed.event === "message") return parsed;
// ignore non-event messages
else return null;
}),
);
return notifications
.filter((notification) => notification !== null)
.map((notification): Notification => {
const topicURL = this.url(`/${notification.topic}`);
return {
id: notification.id,
time: new Date(notification.time * 1000),
title: notification.title ?? topicURL.hostname + topicURL.pathname,
body: notification.message,
};
});
}
}

View File

@@ -0,0 +1,12 @@
import { z } from "zod";
// There are more properties, see: https://docs.ntfy.sh/subscribe/api/#json-message-format
// Not all properties are required for this use case.
export const ntfyNotificationSchema = z.object({
id: z.string(),
time: z.number(),
event: z.string(), // we only care about "message"
topic: z.string(),
title: z.optional(z.string()),
message: z.string(),
});

View File

@@ -0,0 +1,20 @@
import dayjs from "dayjs";
import type { IntegrationKindByCategory } from "@homarr/definitions";
import type { Notification } from "@homarr/integrations";
import { createIntegrationAsync } from "@homarr/integrations";
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
export const notificationsRequestHandler = createCachedIntegrationRequestHandler<
Notification[],
IntegrationKindByCategory<"notifications">,
Record<string, never>
>({
async requestAsync(integration) {
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getNotificationsAsync();
},
cacheDuration: dayjs.duration(5, "minutes"),
queryKey: "notificationsJobStatus",
});

View File

@@ -936,6 +936,10 @@
"realm": {
"label": "Realm",
"newLabel": "New realm"
},
"topic": {
"label": "Topic",
"newLabel": "New topic"
}
}
},
@@ -2359,6 +2363,12 @@
"error": {
"internalServerError": "Failed to fetch Network Controller Summary"
}
},
"notifications": {
"name": "Notifications",
"description": "Display notification history from an integration",
"noItems": "No notifications to display.",
"option": {}
}
},
"widgetPreview": {
@@ -3124,6 +3134,9 @@
"networkController": {
"label": "Network Controller"
},
"refreshNotifications": {
"label": "Notification Updater"
},
"dockerContainers": {
"label": "Docker containers"
}

View File

@@ -28,6 +28,7 @@ import * as minecraftServerStatus from "./minecraft/server-status";
import * as networkControllerStatus from "./network-controller/network-status";
import * as networkControllerSummary from "./network-controller/summary";
import * as notebook from "./notebook";
import * as notifications from "./notifications";
import type { WidgetOptionDefinition } from "./options";
import * as releases from "./releases";
import * as rssFeed from "./rssFeed";
@@ -67,6 +68,7 @@ export const widgetImports = {
minecraftServerStatus,
dockerContainers,
releases,
notifications,
} satisfies WidgetImportRecord;
export type WidgetImports = typeof widgetImports;

View File

@@ -0,0 +1,106 @@
"use client";
import { useMemo } from "react";
import { Card, Flex, Group, ScrollArea, Stack, Text } from "@mantine/core";
import { IconClock } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { useTimeAgo } from "@homarr/common";
import { useScopedI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
export default function NotificationsWidget({ options, integrationIds }: WidgetComponentProps<"notifications">) {
const [notificationIntegrations] = clientApi.widget.notifications.getNotifications.useSuspenseQuery(
{
...options,
integrationIds,
},
{
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
retry: false,
},
);
const utils = clientApi.useUtils();
clientApi.widget.notifications.subscribeNotifications.useSubscription(
{
...options,
integrationIds,
},
{
onData: (data) => {
utils.widget.notifications.getNotifications.setData({ ...options, integrationIds }, (prevData) => {
return prevData?.map((item) => {
if (item.integration.id !== data.integration.id) return item;
return {
data: data.data,
integration: {
...data.integration,
updatedAt: new Date(),
},
};
});
});
},
},
);
const t = useScopedI18n("widget.notifications");
const board = useRequiredBoard();
const sortedNotifications = useMemo(
() =>
notificationIntegrations
.flatMap((integration) => integration.data)
.sort((entryA, entryB) => entryB.time.getTime() - entryA.time.getTime()),
[notificationIntegrations],
);
return (
<ScrollArea className="scroll-area-w100" w="100%" p="sm">
<Stack w={"100%"} gap="sm">
{sortedNotifications.length > 0 ? (
sortedNotifications.map((notification) => (
<Card key={notification.id} withBorder radius={board.itemRadius} w="100%" p="sm">
<Flex gap="sm" direction="column" w="100%">
{notification.title && (
<Text fz="sm" lh="sm" lineClamp={2}>
{notification.title}
</Text>
)}
<Text c="dimmed" size="sm" lineClamp={4} style={{ whiteSpace: "pre-line" }}>
{notification.body}
</Text>
<InfoDisplay date={notification.time} />
</Flex>
</Card>
))
) : (
<Text size="sm" c="dimmed">
{t("noItems")}
</Text>
)}
</Stack>
</ScrollArea>
);
}
const InfoDisplay = ({ date }: { date: Date }) => {
const timeAgo = useTimeAgo(date, 30000); // update every 30sec
return (
<Group gap={5} align={"center"}>
<IconClock size={"1rem"} color={"var(--mantine-color-dimmed)"} />
<Text size="sm" c="dimmed">
{timeAgo}
</Text>
</Group>
);
};

View File

@@ -0,0 +1,14 @@
import { IconMessage } from "@tabler/icons-react";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
export const { componentLoader, definition } = createWidgetDefinition("notifications", {
icon: IconMessage,
createOptions() {
return optionsBuilder.from(() => ({}));
},
supportedIntegrations: getIntegrationKindsByCategory("notifications"),
}).withDynamicImport(() => import("./component"));