diff --git a/.env.example b/.env.example index 1f95edc..78ccf9e 100644 --- a/.env.example +++ b/.env.example @@ -4,4 +4,5 @@ EVE_SSO_SECRET=Secret Key EVE_SSO_CALLBACK_URL=Callback URL (This should be the domain you are hosting at or if run locally it should be http://localhost:3000) NEXT_PUBLIC_PRAISAL_URL=https://praisal.avanto.tk/appraisal/structured.json?persist=no SENTRY_AUTH_TOKEN=Sentry token for error reporting. -LOG_LEVEL=warn \ No newline at end of file +LOG_LEVEL=warn +WEBHOOK_URL=Webhook URL \ No newline at end of file diff --git a/README.md b/README.md index 72d34e1..c1e40a6 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ Features: - Backup to download characters to a file - Rstore from a file. Must be from the same instance! - View the 3D render of the planet with your PI setup by clicking the planet +- Webhook notifications for extractor expiry, storage capacity, and launch pad capacity ## Support with hosting @@ -65,6 +66,7 @@ You will need these env variables from the application settings: EVE_SSO_CLIENT_ID=Client ID EVE_SSO_SECRET=Secret Key EVE_SSO_CALLBACK_URL=Callback URL (This should be the domain you are hosting at or if run locally it should be http://localhost:3000) +WEBHOOK_URL=Discord webhook URL for notifications (optional) ``` ## Run locally @@ -72,6 +74,25 @@ EVE_SSO_CALLBACK_URL=Callback URL (This should be the domain you are hosting at 1. Create .env file in the directory root and populate with env variables you get from the EVE app you created. Example env file: .env.example 2. run `npm run dev` +## Webhook Notifications + +The application supports webhook notifications for various PI events: + +- **Extractor expiry warnings**: Notify when extractors are about to run out (configurable hours before expiry) +- **Extractor expired**: Notify when extractors have run out +- **Storage capacity warnings**: Notify when storage is about to be full (configurable percentage) +- **Storage full**: Notify when storage is at 100% capacity +- **Launch pad capacity warnings**: Notify when launch pads are about to be full (configurable percentage) +- **Launch pad full**: Notify when launch pads are at 100% capacity + +To enable webhook notifications: + +1. Set the `WEBHOOK_URL` environment variable to your Discord webhook URL +2. Enable webhook notifications in the Settings dialog +3. Configure warning thresholds for extractors, storage, and launch pads + +The webhook URL is kept secure in environment variables and not stored in the browser. + ## Run the container 1. Populate the environment variables in .env file diff --git a/compose.yml b/compose.yml index 6fce319..4578f51 100644 --- a/compose.yml +++ b/compose.yml @@ -8,6 +8,7 @@ services: - EVE_SSO_SECRET=${EVE_SSO_SECRET} - NEXT_PUBLIC_PRAISAL_URL=${NEXT_PUBLIC_PRAISAL_URL} - SENTRY_AUTH_TOKEN=${SENTRY_AUTH_TOKEN} + - WEBHOOK_URL=${WEBHOOK_URL} - LOG_LEVEL=warn ports: - 3000:3000 diff --git a/src/app/components/Settings/SettingsButtons.tsx b/src/app/components/Settings/SettingsButtons.tsx index cc79a77..a515a5a 100644 --- a/src/app/components/Settings/SettingsButtons.tsx +++ b/src/app/components/Settings/SettingsButtons.tsx @@ -1,27 +1,27 @@ import { - ColorContext, - ColorSelectionType, - SessionContext, + ColorContext, + ColorSelectionType, + SessionContext, } from "@/app/context/Context"; import { - Button, - Dialog, - DialogActions, - DialogContent, - DialogTitle, - Tooltip, - Typography, - TextField, - Box, - FormControlLabel, - Checkbox, + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Tooltip, + Typography, + TextField, + Box, + FormControlLabel, + Checkbox, } from "@mui/material"; -import { ColorChangeHandler, ColorResult, CompactPicker } from "react-color"; +import { ColorResult, CompactPicker } from "react-color"; import React, { useState, useContext } from "react"; export const SettingsButton = () => { const { colors, setColors } = useContext(ColorContext); - const { balanceThreshold, setBalanceThreshold, showProductIcons, setShowProductIcons } = useContext(SessionContext); + const { balanceThreshold, setBalanceThreshold, showProductIcons, setShowProductIcons, webhookConfig, setWebhookConfig } = useContext(SessionContext); const [open, setOpen] = useState(false); const handleClickOpen = () => { @@ -51,6 +51,44 @@ export const SettingsButton = () => { setShowProductIcons(event.target.checked); }; + const handleWebhookEnabledChange = (event: React.ChangeEvent) => { + setWebhookConfig({ + ...webhookConfig, + enabled: event.target.checked + }); + }; + + + const handleExtractorWarningHoursChange = (event: React.ChangeEvent) => { + const value = parseInt(event.target.value); + if (!isNaN(value) && value >= 0 && value <= 168) { // Max 1 week + setWebhookConfig({ + ...webhookConfig, + extractorWarningHours: value + }); + } + }; + + const handleStorageWarningPercentChange = (event: React.ChangeEvent) => { + const value = parseInt(event.target.value); + if (!isNaN(value) && value >= 0 && value <= 100) { + setWebhookConfig({ + ...webhookConfig, + storageWarningPercent: value + }); + } + }; + + const handleLaunchpadWarningPercentChange = (event: React.ChangeEvent) => { + const value = parseInt(event.target.value); + if (!isNaN(value) && value >= 0 && value <= 100) { + setWebhookConfig({ + ...webhookConfig, + launchpadWarningPercent: value + }); + } + }; + return ( <> @@ -93,6 +131,58 @@ export const SettingsButton = () => { error={balanceThreshold < 0 || balanceThreshold > 100000} /> + + Webhook Notifications + + } + label="Enable webhook notifications" + /> + {webhookConfig.enabled && ( + <> + + Webhook URL is configured via the WEBHOOK_URL environment variable. + + 168} + /> + 100} + /> + 100} + /> + + )} + {Object.keys(colors).map((key) => { return (
diff --git a/src/app/context/Context.tsx b/src/app/context/Context.tsx index bdfb394..985f497 100644 --- a/src/app/context/Context.tsx +++ b/src/app/context/Context.tsx @@ -1,5 +1,5 @@ import { EvePraisalResult } from "@/eve-praisal"; -import { AccessToken, CharacterUpdate, PlanetConfig } from "@/types"; +import { AccessToken, CharacterUpdate, PlanetConfig, WebhookConfig } from "@/types"; import { Dispatch, SetStateAction, createContext } from "react"; export const CharacterContext = createContext<{ @@ -41,6 +41,8 @@ export const SessionContext = createContext<{ setBalanceThreshold: Dispatch>; showProductIcons: boolean; setShowProductIcons: (show: boolean) => void; + webhookConfig: WebhookConfig; + setWebhookConfig: (config: WebhookConfig) => void; }>({ sessionReady: false, refreshSession: () => {}, @@ -70,6 +72,13 @@ export const SessionContext = createContext<{ setBalanceThreshold: () => {}, showProductIcons: false, setShowProductIcons: () => {}, + webhookConfig: { + enabled: false, + extractorWarningHours: 2, + storageWarningPercent: 85, + launchpadWarningPercent: 90, + }, + setWebhookConfig: () => {}, }); export type ColorSelectionType = { diff --git a/src/app/page.tsx b/src/app/page.tsx index cac18d7..298ec4c 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -4,20 +4,21 @@ import "@fontsource/roboto/400.css"; import "@fontsource/roboto/500.css"; import "@fontsource/roboto/700.css"; import { memo, useCallback, useEffect, useState, Suspense } from "react"; -import { AccessToken, CharacterUpdate, Env, PlanetWithInfo } from "../types"; +import { AccessToken, CharacterUpdate, Env, PlanetWithInfo, WebhookConfig } from "../types"; import { MainGrid } from "./components/MainGrid"; import { refreshToken } from "@/esi-sso"; import { - CharacterContext, - ColorContext, - ColorSelectionType, - SessionContext, - defaultColors, + CharacterContext, + ColorContext, + ColorSelectionType, + SessionContext, + defaultColors, } from "./context/Context"; import { useSearchParams } from "next/navigation"; import { EvePraisalResult, fetchAllPrices } from "@/eve-praisal"; import { getPlanet, getPlanetUniverse, getPlanets } from "@/planets"; import { PlanetConfig } from "@/types"; +import { notificationService } from "@/utils/notificationService"; // Add batch processing utility const processInBatches = async ( @@ -47,6 +48,12 @@ const Home = () => { const [balanceThreshold, setBalanceThreshold] = useState(1000); const [showProductIcons, setShowProductIcons] = useState(false); const [extractionTimeMode, setExtractionTimeMode] = useState(false); + const [webhookConfig, setWebhookConfig] = useState({ + enabled: false, + extractorWarningHours: 2, + storageWarningPercent: 85, + launchpadWarningPercent: 90, + }); const [colors, setColors] = useState(defaultColors); const [alertMode, setAlertMode] = useState(false); @@ -253,6 +260,17 @@ const Home = () => { localStorage.setItem('extractionTimeMode', extractionTimeMode.toString()); }, [extractionTimeMode]); + useEffect(() => { + const storedWebhookConfig = localStorage.getItem("webhookConfig"); + if (storedWebhookConfig) { + setWebhookConfig(JSON.parse(storedWebhookConfig)); + } + }, []); + + useEffect(() => { + localStorage.setItem("webhookConfig", JSON.stringify(webhookConfig)); + }, [webhookConfig]); + useEffect(() => { fetch("api/env") .then((r) => r.json()) @@ -284,10 +302,14 @@ const Home = () => { refreshSession(characters) .then(saveCharacters) .then(initializeCharacterPlanets) - .then(setCharacters); + .then((updatedCharacters) => { + setCharacters(updatedCharacters); + // Check for notifications after updating characters + notificationService.checkAndNotify(updatedCharacters, webhookConfig); + }); }, ESI_CACHE_TIME_MS); return () => clearInterval(interval); - }); + }, [webhookConfig]); return ( { setBalanceThreshold, showProductIcons, setShowProductIcons, + webhookConfig, + setWebhookConfig, }} > { + if (req.method === "POST") { + try { + const { content }: { content: string } = req.body; + + if (!content) { + return res.status(400).json({ error: "Content is required" }); + } + + const webhookUrl = process.env.WEBHOOK_URL; + if (!webhookUrl) { + return res.status(500).json({ error: "Webhook URL not configured" }); + } + + const payload: WebhookPayload = { content }; + + logger.info({ + event: 'webhook_send_start', + url: webhookUrl.replace(/\/[^\/]*$/, '/***'), // Mask the webhook token + contentLength: content.length + }); + + const response = await fetch(webhookUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(payload), + }); + + if (!response.ok) { + throw new Error(`Webhook request failed with status ${response.status}`); + } + + logger.info({ + event: 'webhook_send_success', + url: webhookUrl.replace(/\/[^\/]*$/, '/***'), + status: response.status + }); + + return res.json({ success: true }); + } catch (error) { + logger.error({ + event: 'webhook_send_failed', + error: error instanceof Error ? error.message : String(error), + body: req.body + }); + return res.status(500).json({ error: "Failed to send webhook" }); + } + } else { + logger.warn({ + event: 'invalid_method', + method: req.method, + path: req.url + }); + res.status(404).end(); + } +}; + +export default handler; diff --git a/src/types.ts b/src/types.ts index c2e6aad..0f5d38a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -123,3 +123,19 @@ export interface PlanetConfig { planetId: number; excludeFromTotals: boolean; } + +export interface WebhookConfig { + enabled: boolean; + extractorWarningHours: number; + storageWarningPercent: number; + launchpadWarningPercent: number; +} + +export interface NotificationState { + extractorExpired: Set; + extractorWarning: Set; + storageFull: Set; + storageWarning: Set; + launchpadFull: Set; + launchpadWarning: Set; +} diff --git a/src/utils/notificationService.ts b/src/utils/notificationService.ts new file mode 100644 index 0000000..f870053 --- /dev/null +++ b/src/utils/notificationService.ts @@ -0,0 +1,180 @@ +import { DateTime } from "luxon"; +import { AccessToken, WebhookConfig, NotificationState, PlanetCalculations, PlanetWithInfo } from "@/types"; +import { planetCalculations } from "@/planets"; +import { LAUNCHPAD_IDS } from "@/const"; + +export class NotificationService { + private notificationState: NotificationState = { + extractorExpired: new Set(), + extractorWarning: new Set(), + storageFull: new Set(), + storageWarning: new Set(), + launchpadFull: new Set(), + launchpadWarning: new Set(), + }; + + private sendWebhook = async (content: string): Promise => { + try { + const response = await fetch("/api/webhook", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ content }), + }); + + if (!response.ok) { + throw new Error(`Webhook request failed with status ${response.status}`); + } + } catch (error) { + console.error("Failed to send webhook:", error); + } + }; + + private getPlanetIdentifier = (characterName: string, planetId: number): string => { + return `${characterName}-${planetId}`; + }; + + private checkExtractorExpiry = ( + character: AccessToken, + planet: PlanetWithInfo, + planetCalculations: PlanetCalculations, + webhookConfig: WebhookConfig + ): string[] => { + const notifications: string[] = []; + const now = DateTime.now(); + const warningTime = now.plus({ hours: webhookConfig.extractorWarningHours }); + + planetCalculations.extractors.forEach((extractor) => { + if (!extractor.expiry_time) return; + + const expiryTime = DateTime.fromISO(extractor.expiry_time); + const planetId = planet.planet_id; + const identifier = this.getPlanetIdentifier(character.character.name, planetId); + + // Check if extractor has expired + if (expiryTime <= now) { + if (!this.notificationState.extractorExpired.has(identifier)) { + this.notificationState.extractorExpired.add(identifier); + notifications.push( + `🚨 **EXTRACTOR EXPIRED** - ${character.character.name} Planet ${planetId} - Extractor expired at ${expiryTime.toFormat("yyyy-MM-dd HH:mm")}` + ); + } + } else { + // Remove from expired set if it's no longer expired + this.notificationState.extractorExpired.delete(identifier); + } + + // Check if extractor is about to expire (warning) + if (expiryTime <= warningTime && expiryTime > now) { + if (!this.notificationState.extractorWarning.has(identifier)) { + this.notificationState.extractorWarning.add(identifier); + const hoursLeft = Math.round(expiryTime.diff(now, "hours").hours); + notifications.push( + `⚠️ **EXTRACTOR WARNING** - ${character.character.name} Planet ${planetId} - Extractor expires in ${hoursLeft} hours (${expiryTime.toFormat("yyyy-MM-dd HH:mm")})` + ); + } + } else { + // Remove from warning set if it's no longer in warning period + this.notificationState.extractorWarning.delete(identifier); + } + }); + + return notifications; + }; + + private checkStorageCapacity = ( + character: AccessToken, + planet: PlanetWithInfo, + planetCalculations: PlanetCalculations, + webhookConfig: WebhookConfig + ): string[] => { + const notifications: string[] = []; + + planetCalculations.storageInfo.forEach((storage) => { + const planetId = planet.planet_id; + const identifier = this.getPlanetIdentifier(character.character.name, planetId); + const isLaunchpad = LAUNCHPAD_IDS.includes(storage.type_id); + const warningThreshold = isLaunchpad ? webhookConfig.launchpadWarningPercent : webhookConfig.storageWarningPercent; + + // Check if storage is full (100%) + if (storage.fillRate >= 100) { + const storageType = isLaunchpad ? "Launchpad" : "Storage"; + if (!this.notificationState.storageFull.has(identifier)) { + this.notificationState.storageFull.add(identifier); + notifications.push( + `🚨 **${storageType.toUpperCase()} FULL** - ${character.character.name} Planet ${planetId} - ${storageType} is at ${storage.fillRate.toFixed(1)}% capacity` + ); + } + } else { + this.notificationState.storageFull.delete(identifier); + } + + // Check if storage is about to be full (warning threshold) + if (storage.fillRate >= warningThreshold && storage.fillRate < 100) { + const storageType = isLaunchpad ? "Launchpad" : "Storage"; + if (!this.notificationState.storageWarning.has(identifier)) { + this.notificationState.storageWarning.add(identifier); + notifications.push( + `⚠️ **${storageType.toUpperCase()} WARNING** - ${character.character.name} Planet ${planetId} - ${storageType} is at ${storage.fillRate.toFixed(1)}% capacity` + ); + } + } else { + this.notificationState.storageWarning.delete(identifier); + } + }); + + return notifications; + }; + + public checkAndNotify = async ( + characters: AccessToken[], + webhookConfig: WebhookConfig + ): Promise => { + if (!webhookConfig.enabled) { + return; + } + + // Get webhook URL from environment variables + const webhookUrl = process.env.WEBHOOK_URL; + if (!webhookUrl) { + console.warn("Webhook notifications enabled but WEBHOOK_URL environment variable not set"); + return; + } + + const allNotifications: string[] = []; + + for (const character of characters) { + for (const planet of character.planets) { + const planetDetails = planetCalculations(planet); + + // Check extractor expiry + const extractorNotifications = this.checkExtractorExpiry(character, planet, planetDetails, webhookConfig); + allNotifications.push(...extractorNotifications); + + // Check storage capacity + const storageNotifications = this.checkStorageCapacity(character, planet, planetDetails, webhookConfig); + allNotifications.push(...storageNotifications); + } + } + + // Send all notifications in a single webhook + if (allNotifications.length > 0) { + const content = allNotifications.join("\n"); + await this.sendWebhook(content); + } + }; + + public resetNotificationState = (): void => { + this.notificationState = { + extractorExpired: new Set(), + extractorWarning: new Set(), + storageFull: new Set(), + storageWarning: new Set(), + launchpadFull: new Set(), + launchpadWarning: new Set(), + }; + }; +} + +export const notificationService = new NotificationService();