Add webhook notification feature for extractor expiry and storage capacity alerts
This commit is contained in:
@@ -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)
|
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
|
NEXT_PUBLIC_PRAISAL_URL=https://praisal.avanto.tk/appraisal/structured.json?persist=no
|
||||||
SENTRY_AUTH_TOKEN=Sentry token for error reporting.
|
SENTRY_AUTH_TOKEN=Sentry token for error reporting.
|
||||||
LOG_LEVEL=warn
|
LOG_LEVEL=warn
|
||||||
|
WEBHOOK_URL=Webhook URL
|
21
README.md
21
README.md
@@ -20,6 +20,7 @@ Features:
|
|||||||
- Backup to download characters to a file
|
- Backup to download characters to a file
|
||||||
- Rstore from a file. Must be from the same instance!
|
- 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
|
- 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
|
## 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_CLIENT_ID=Client ID
|
||||||
EVE_SSO_SECRET=Secret Key
|
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)
|
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
|
## 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
|
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`
|
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
|
## Run the container
|
||||||
|
|
||||||
1. Populate the environment variables in .env file
|
1. Populate the environment variables in .env file
|
||||||
|
@@ -8,6 +8,7 @@ services:
|
|||||||
- EVE_SSO_SECRET=${EVE_SSO_SECRET}
|
- EVE_SSO_SECRET=${EVE_SSO_SECRET}
|
||||||
- NEXT_PUBLIC_PRAISAL_URL=${NEXT_PUBLIC_PRAISAL_URL}
|
- NEXT_PUBLIC_PRAISAL_URL=${NEXT_PUBLIC_PRAISAL_URL}
|
||||||
- SENTRY_AUTH_TOKEN=${SENTRY_AUTH_TOKEN}
|
- SENTRY_AUTH_TOKEN=${SENTRY_AUTH_TOKEN}
|
||||||
|
- WEBHOOK_URL=${WEBHOOK_URL}
|
||||||
- LOG_LEVEL=warn
|
- LOG_LEVEL=warn
|
||||||
ports:
|
ports:
|
||||||
- 3000:3000
|
- 3000:3000
|
||||||
|
@@ -1,27 +1,27 @@
|
|||||||
import {
|
import {
|
||||||
ColorContext,
|
ColorContext,
|
||||||
ColorSelectionType,
|
ColorSelectionType,
|
||||||
SessionContext,
|
SessionContext,
|
||||||
} from "@/app/context/Context";
|
} from "@/app/context/Context";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogActions,
|
DialogActions,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
Typography,
|
Typography,
|
||||||
TextField,
|
TextField,
|
||||||
Box,
|
Box,
|
||||||
FormControlLabel,
|
FormControlLabel,
|
||||||
Checkbox,
|
Checkbox,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import { ColorChangeHandler, ColorResult, CompactPicker } from "react-color";
|
import { ColorResult, CompactPicker } from "react-color";
|
||||||
import React, { useState, useContext } from "react";
|
import React, { useState, useContext } from "react";
|
||||||
|
|
||||||
export const SettingsButton = () => {
|
export const SettingsButton = () => {
|
||||||
const { colors, setColors } = useContext(ColorContext);
|
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 [open, setOpen] = useState(false);
|
||||||
|
|
||||||
const handleClickOpen = () => {
|
const handleClickOpen = () => {
|
||||||
@@ -51,6 +51,44 @@ export const SettingsButton = () => {
|
|||||||
setShowProductIcons(event.target.checked);
|
setShowProductIcons(event.target.checked);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleWebhookEnabledChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setWebhookConfig({
|
||||||
|
...webhookConfig,
|
||||||
|
enabled: event.target.checked
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const handleExtractorWarningHoursChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
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<HTMLInputElement>) => {
|
||||||
|
const value = parseInt(event.target.value);
|
||||||
|
if (!isNaN(value) && value >= 0 && value <= 100) {
|
||||||
|
setWebhookConfig({
|
||||||
|
...webhookConfig,
|
||||||
|
storageWarningPercent: value
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLaunchpadWarningPercentChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const value = parseInt(event.target.value);
|
||||||
|
if (!isNaN(value) && value >= 0 && value <= 100) {
|
||||||
|
setWebhookConfig({
|
||||||
|
...webhookConfig,
|
||||||
|
launchpadWarningPercent: value
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip title="Toggle settings dialog">
|
<Tooltip title="Toggle settings dialog">
|
||||||
<>
|
<>
|
||||||
@@ -93,6 +131,58 @@ export const SettingsButton = () => {
|
|||||||
error={balanceThreshold < 0 || balanceThreshold > 100000}
|
error={balanceThreshold < 0 || balanceThreshold > 100000}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
<Box sx={{ mt: 2 }}>
|
||||||
|
<Typography variant="subtitle1">Webhook Notifications</Typography>
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Checkbox
|
||||||
|
checked={webhookConfig.enabled}
|
||||||
|
onChange={handleWebhookEnabledChange}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label="Enable webhook notifications"
|
||||||
|
/>
|
||||||
|
{webhookConfig.enabled && (
|
||||||
|
<>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||||
|
Webhook URL is configured via the WEBHOOK_URL environment variable.
|
||||||
|
</Typography>
|
||||||
|
<TextField
|
||||||
|
type="number"
|
||||||
|
label="Extractor Warning Hours"
|
||||||
|
value={webhookConfig.extractorWarningHours}
|
||||||
|
onChange={handleExtractorWarningHoursChange}
|
||||||
|
fullWidth
|
||||||
|
margin="normal"
|
||||||
|
inputProps={{ min: 0, max: 168 }}
|
||||||
|
helperText="Hours before extractor expiry to send warning (0-168)"
|
||||||
|
error={webhookConfig.extractorWarningHours < 0 || webhookConfig.extractorWarningHours > 168}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
type="number"
|
||||||
|
label="Storage Warning %"
|
||||||
|
value={webhookConfig.storageWarningPercent}
|
||||||
|
onChange={handleStorageWarningPercentChange}
|
||||||
|
fullWidth
|
||||||
|
margin="normal"
|
||||||
|
inputProps={{ min: 0, max: 100 }}
|
||||||
|
helperText="Storage capacity percentage to trigger warning (0-100)"
|
||||||
|
error={webhookConfig.storageWarningPercent < 0 || webhookConfig.storageWarningPercent > 100}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
type="number"
|
||||||
|
label="Launchpad Warning %"
|
||||||
|
value={webhookConfig.launchpadWarningPercent}
|
||||||
|
onChange={handleLaunchpadWarningPercentChange}
|
||||||
|
fullWidth
|
||||||
|
margin="normal"
|
||||||
|
inputProps={{ min: 0, max: 100 }}
|
||||||
|
helperText="Launchpad capacity percentage to trigger warning (0-100)"
|
||||||
|
error={webhookConfig.launchpadWarningPercent < 0 || webhookConfig.launchpadWarningPercent > 100}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
{Object.keys(colors).map((key) => {
|
{Object.keys(colors).map((key) => {
|
||||||
return (
|
return (
|
||||||
<div key={`color-row-${key}`}>
|
<div key={`color-row-${key}`}>
|
||||||
|
@@ -1,5 +1,5 @@
|
|||||||
import { EvePraisalResult } from "@/eve-praisal";
|
import { EvePraisalResult } from "@/eve-praisal";
|
||||||
import { AccessToken, CharacterUpdate, PlanetConfig } from "@/types";
|
import { AccessToken, CharacterUpdate, PlanetConfig, WebhookConfig } from "@/types";
|
||||||
import { Dispatch, SetStateAction, createContext } from "react";
|
import { Dispatch, SetStateAction, createContext } from "react";
|
||||||
|
|
||||||
export const CharacterContext = createContext<{
|
export const CharacterContext = createContext<{
|
||||||
@@ -41,6 +41,8 @@ export const SessionContext = createContext<{
|
|||||||
setBalanceThreshold: Dispatch<SetStateAction<number>>;
|
setBalanceThreshold: Dispatch<SetStateAction<number>>;
|
||||||
showProductIcons: boolean;
|
showProductIcons: boolean;
|
||||||
setShowProductIcons: (show: boolean) => void;
|
setShowProductIcons: (show: boolean) => void;
|
||||||
|
webhookConfig: WebhookConfig;
|
||||||
|
setWebhookConfig: (config: WebhookConfig) => void;
|
||||||
}>({
|
}>({
|
||||||
sessionReady: false,
|
sessionReady: false,
|
||||||
refreshSession: () => {},
|
refreshSession: () => {},
|
||||||
@@ -70,6 +72,13 @@ export const SessionContext = createContext<{
|
|||||||
setBalanceThreshold: () => {},
|
setBalanceThreshold: () => {},
|
||||||
showProductIcons: false,
|
showProductIcons: false,
|
||||||
setShowProductIcons: () => {},
|
setShowProductIcons: () => {},
|
||||||
|
webhookConfig: {
|
||||||
|
enabled: false,
|
||||||
|
extractorWarningHours: 2,
|
||||||
|
storageWarningPercent: 85,
|
||||||
|
launchpadWarningPercent: 90,
|
||||||
|
},
|
||||||
|
setWebhookConfig: () => {},
|
||||||
});
|
});
|
||||||
|
|
||||||
export type ColorSelectionType = {
|
export type ColorSelectionType = {
|
||||||
|
@@ -4,20 +4,21 @@ import "@fontsource/roboto/400.css";
|
|||||||
import "@fontsource/roboto/500.css";
|
import "@fontsource/roboto/500.css";
|
||||||
import "@fontsource/roboto/700.css";
|
import "@fontsource/roboto/700.css";
|
||||||
import { memo, useCallback, useEffect, useState, Suspense } from "react";
|
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 { MainGrid } from "./components/MainGrid";
|
||||||
import { refreshToken } from "@/esi-sso";
|
import { refreshToken } from "@/esi-sso";
|
||||||
import {
|
import {
|
||||||
CharacterContext,
|
CharacterContext,
|
||||||
ColorContext,
|
ColorContext,
|
||||||
ColorSelectionType,
|
ColorSelectionType,
|
||||||
SessionContext,
|
SessionContext,
|
||||||
defaultColors,
|
defaultColors,
|
||||||
} from "./context/Context";
|
} from "./context/Context";
|
||||||
import { useSearchParams } from "next/navigation";
|
import { useSearchParams } from "next/navigation";
|
||||||
import { EvePraisalResult, fetchAllPrices } from "@/eve-praisal";
|
import { EvePraisalResult, fetchAllPrices } from "@/eve-praisal";
|
||||||
import { getPlanet, getPlanetUniverse, getPlanets } from "@/planets";
|
import { getPlanet, getPlanetUniverse, getPlanets } from "@/planets";
|
||||||
import { PlanetConfig } from "@/types";
|
import { PlanetConfig } from "@/types";
|
||||||
|
import { notificationService } from "@/utils/notificationService";
|
||||||
|
|
||||||
// Add batch processing utility
|
// Add batch processing utility
|
||||||
const processInBatches = async <T, R>(
|
const processInBatches = async <T, R>(
|
||||||
@@ -47,6 +48,12 @@ const Home = () => {
|
|||||||
const [balanceThreshold, setBalanceThreshold] = useState(1000);
|
const [balanceThreshold, setBalanceThreshold] = useState(1000);
|
||||||
const [showProductIcons, setShowProductIcons] = useState(false);
|
const [showProductIcons, setShowProductIcons] = useState(false);
|
||||||
const [extractionTimeMode, setExtractionTimeMode] = useState(false);
|
const [extractionTimeMode, setExtractionTimeMode] = useState(false);
|
||||||
|
const [webhookConfig, setWebhookConfig] = useState<WebhookConfig>({
|
||||||
|
enabled: false,
|
||||||
|
extractorWarningHours: 2,
|
||||||
|
storageWarningPercent: 85,
|
||||||
|
launchpadWarningPercent: 90,
|
||||||
|
});
|
||||||
|
|
||||||
const [colors, setColors] = useState<ColorSelectionType>(defaultColors);
|
const [colors, setColors] = useState<ColorSelectionType>(defaultColors);
|
||||||
const [alertMode, setAlertMode] = useState(false);
|
const [alertMode, setAlertMode] = useState(false);
|
||||||
@@ -253,6 +260,17 @@ const Home = () => {
|
|||||||
localStorage.setItem('extractionTimeMode', extractionTimeMode.toString());
|
localStorage.setItem('extractionTimeMode', extractionTimeMode.toString());
|
||||||
}, [extractionTimeMode]);
|
}, [extractionTimeMode]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const storedWebhookConfig = localStorage.getItem("webhookConfig");
|
||||||
|
if (storedWebhookConfig) {
|
||||||
|
setWebhookConfig(JSON.parse(storedWebhookConfig));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem("webhookConfig", JSON.stringify(webhookConfig));
|
||||||
|
}, [webhookConfig]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch("api/env")
|
fetch("api/env")
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
@@ -284,10 +302,14 @@ const Home = () => {
|
|||||||
refreshSession(characters)
|
refreshSession(characters)
|
||||||
.then(saveCharacters)
|
.then(saveCharacters)
|
||||||
.then(initializeCharacterPlanets)
|
.then(initializeCharacterPlanets)
|
||||||
.then(setCharacters);
|
.then((updatedCharacters) => {
|
||||||
|
setCharacters(updatedCharacters);
|
||||||
|
// Check for notifications after updating characters
|
||||||
|
notificationService.checkAndNotify(updatedCharacters, webhookConfig);
|
||||||
|
});
|
||||||
}, ESI_CACHE_TIME_MS);
|
}, ESI_CACHE_TIME_MS);
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
});
|
}, [webhookConfig]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SessionContext.Provider
|
<SessionContext.Provider
|
||||||
@@ -312,6 +334,8 @@ const Home = () => {
|
|||||||
setBalanceThreshold,
|
setBalanceThreshold,
|
||||||
showProductIcons,
|
showProductIcons,
|
||||||
setShowProductIcons,
|
setShowProductIcons,
|
||||||
|
webhookConfig,
|
||||||
|
setWebhookConfig,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<CharacterContext.Provider
|
<CharacterContext.Provider
|
||||||
|
67
src/pages/api/webhook.ts
Normal file
67
src/pages/api/webhook.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
import logger from "@/utils/logger";
|
||||||
|
|
||||||
|
interface WebhookPayload {
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||||
|
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;
|
16
src/types.ts
16
src/types.ts
@@ -123,3 +123,19 @@ export interface PlanetConfig {
|
|||||||
planetId: number;
|
planetId: number;
|
||||||
excludeFromTotals: boolean;
|
excludeFromTotals: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface WebhookConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
extractorWarningHours: number;
|
||||||
|
storageWarningPercent: number;
|
||||||
|
launchpadWarningPercent: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotificationState {
|
||||||
|
extractorExpired: Set<string>;
|
||||||
|
extractorWarning: Set<string>;
|
||||||
|
storageFull: Set<string>;
|
||||||
|
storageWarning: Set<string>;
|
||||||
|
launchpadFull: Set<string>;
|
||||||
|
launchpadWarning: Set<string>;
|
||||||
|
}
|
||||||
|
180
src/utils/notificationService.ts
Normal file
180
src/utils/notificationService.ts
Normal file
@@ -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<void> => {
|
||||||
|
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<void> => {
|
||||||
|
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();
|
Reference in New Issue
Block a user