Compare commits
3 Commits
9c8ade4c75
...
b5dc367713
Author | SHA1 | Date | |
---|---|---|---|
b5dc367713 | |||
b56a950f04 | |||
46289d4667 |
@@ -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
|
56
README.md
56
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,9 @@ 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 Configuration (optional)
|
||||||
|
WEBHOOK_URL=Discord webhook URL for notifications
|
||||||
```
|
```
|
||||||
|
|
||||||
## Run locally
|
## Run locally
|
||||||
@@ -72,6 +76,58 @@ 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
|
||||||
|
|
||||||
|
### Webhook Configuration
|
||||||
|
|
||||||
|
To enable webhook notifications:
|
||||||
|
|
||||||
|
1. Set the `WEBHOOK_URL` environment variable to your Discord webhook URL or other webhook endpoint
|
||||||
|
2. Open the Settings dialog in the application and enable webhook notifications
|
||||||
|
3. Configure thresholds in the Settings dialog:
|
||||||
|
- **Extractor Expiry Warning**: How early to warn about extractor expiry (ISO 8601 duration format, default: P12H = 12 hours)
|
||||||
|
- **Storage Warning Threshold**: Storage fill percentage for warnings (default: 85%)
|
||||||
|
- **Storage Critical Threshold**: Storage fill percentage for critical alerts (default: 100%)
|
||||||
|
|
||||||
|
### Webhook Behavior
|
||||||
|
|
||||||
|
- **Automatic checks**: Runs every minute to check extractor expiry and storage levels
|
||||||
|
- **Data fetch checks**: Also checks when planet data is refreshed
|
||||||
|
- **Duplicate prevention**: Won't send the same alert repeatedly within cooldown periods
|
||||||
|
- **State reset**: Extractor alerts reset when extractors are restarted with fresh cycles
|
||||||
|
|
||||||
|
### Webhook Payload Format
|
||||||
|
|
||||||
|
The webhook sends JSON payloads with this structure:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "extractor_expiring|extractor_expired|storage_almost_full|storage_full|launchpad_almost_full|launchpad_full",
|
||||||
|
"message": "Human readable message",
|
||||||
|
"characterName": "Character Name",
|
||||||
|
"planetName": "Planet Name",
|
||||||
|
"details": {
|
||||||
|
"extractorType": "Product Type",
|
||||||
|
"hoursRemaining": 1.5,
|
||||||
|
"storageUsed": 8500,
|
||||||
|
"storageCapacity": 10000,
|
||||||
|
"fillPercentage": 85.0
|
||||||
|
},
|
||||||
|
"timestamp": "2024-01-01T12:00:00.000Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
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,28 @@
|
|||||||
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,
|
||||||
|
Divider,
|
||||||
} 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, webhookServerEnabled } = useContext(SessionContext);
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
const handleClickOpen = () => {
|
const handleClickOpen = () => {
|
||||||
@@ -51,6 +52,32 @@ export const SettingsButton = () => {
|
|||||||
setShowProductIcons(event.target.checked);
|
setShowProductIcons(event.target.checked);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleWebhookEnabledChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
if (!webhookServerEnabled && event.target.checked) {
|
||||||
|
// Don't allow enabling if server doesn't support it
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setWebhookConfig(prev => ({ ...prev, enabled: event.target.checked }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExpiryThresholdChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setWebhookConfig(prev => ({ ...prev, expiryThreshold: event.target.value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStorageWarningChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const value = parseInt(event.target.value);
|
||||||
|
if (!isNaN(value) && value >= 0 && value <= 100) {
|
||||||
|
setWebhookConfig(prev => ({ ...prev, storageWarningThreshold: value }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStorageCriticalChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const value = parseInt(event.target.value);
|
||||||
|
if (!isNaN(value) && value >= 0 && value <= 100) {
|
||||||
|
setWebhookConfig(prev => ({ ...prev, storageCriticalThreshold: value }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip title="Toggle settings dialog">
|
<Tooltip title="Toggle settings dialog">
|
||||||
<>
|
<>
|
||||||
@@ -93,6 +120,66 @@ export const SettingsButton = () => {
|
|||||||
error={balanceThreshold < 0 || balanceThreshold > 100000}
|
error={balanceThreshold < 0 || balanceThreshold > 100000}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ mt: 3 }}>
|
||||||
|
<Divider sx={{ mb: 2 }} />
|
||||||
|
<Typography variant="subtitle1">Webhook Notifications</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||||
|
Configure alerts for extractor expiry and storage capacity warnings.
|
||||||
|
{!webhookServerEnabled && " (Server webhook support not available - WEBHOOK_URL not configured)"}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Checkbox
|
||||||
|
checked={webhookConfig.enabled && webhookServerEnabled}
|
||||||
|
onChange={handleWebhookEnabledChange}
|
||||||
|
disabled={!webhookServerEnabled}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label="Enable webhook notifications"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{webhookConfig.enabled && webhookServerEnabled && (
|
||||||
|
<>
|
||||||
|
<TextField
|
||||||
|
label="Extractor Expiry Warning"
|
||||||
|
value={webhookConfig.expiryThreshold}
|
||||||
|
onChange={handleExpiryThresholdChange}
|
||||||
|
fullWidth
|
||||||
|
margin="normal"
|
||||||
|
helperText="ISO 8601 duration format (e.g., P12H for 12 hours, P1D for 1 day)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
type="number"
|
||||||
|
label="Storage Warning Threshold (%)"
|
||||||
|
value={webhookConfig.storageWarningThreshold}
|
||||||
|
onChange={handleStorageWarningChange}
|
||||||
|
fullWidth
|
||||||
|
margin="normal"
|
||||||
|
inputProps={{ min: 0, max: 100 }}
|
||||||
|
helperText="Alert when storage reaches this percentage (0-100)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
type="number"
|
||||||
|
label="Storage Critical Threshold (%)"
|
||||||
|
value={webhookConfig.storageCriticalThreshold}
|
||||||
|
onChange={handleStorageCriticalChange}
|
||||||
|
fullWidth
|
||||||
|
margin="normal"
|
||||||
|
inputProps={{ min: 0, max: 100 }}
|
||||||
|
helperText="Alert when storage is completely full (usually 100)"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ mt: 3 }}>
|
||||||
|
<Divider sx={{ mb: 2 }} />
|
||||||
|
<Typography variant="subtitle1">Colors</Typography>
|
||||||
|
</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,6 @@
|
|||||||
import { EvePraisalResult } from "@/eve-praisal";
|
import { EvePraisalResult } from "@/eve-praisal";
|
||||||
import { AccessToken, CharacterUpdate, PlanetConfig } from "@/types";
|
import { AccessToken, CharacterUpdate, PlanetConfig } from "@/types";
|
||||||
|
import { WebhookConfig } from "@/types/webhook";
|
||||||
import { Dispatch, SetStateAction, createContext } from "react";
|
import { Dispatch, SetStateAction, createContext } from "react";
|
||||||
|
|
||||||
export const CharacterContext = createContext<{
|
export const CharacterContext = createContext<{
|
||||||
@@ -41,6 +42,9 @@ 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: Dispatch<SetStateAction<WebhookConfig>>;
|
||||||
|
webhookServerEnabled: boolean;
|
||||||
}>({
|
}>({
|
||||||
sessionReady: false,
|
sessionReady: false,
|
||||||
refreshSession: () => {},
|
refreshSession: () => {},
|
||||||
@@ -70,6 +74,14 @@ export const SessionContext = createContext<{
|
|||||||
setBalanceThreshold: () => {},
|
setBalanceThreshold: () => {},
|
||||||
showProductIcons: false,
|
showProductIcons: false,
|
||||||
setShowProductIcons: () => {},
|
setShowProductIcons: () => {},
|
||||||
|
webhookConfig: {
|
||||||
|
enabled: false,
|
||||||
|
expiryThreshold: 'P12H',
|
||||||
|
storageWarningThreshold: 85,
|
||||||
|
storageCriticalThreshold: 100
|
||||||
|
},
|
||||||
|
setWebhookConfig: () => {},
|
||||||
|
webhookServerEnabled: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
export type ColorSelectionType = {
|
export type ColorSelectionType = {
|
||||||
|
208
src/app/page.tsx
208
src/app/page.tsx
@@ -8,16 +8,20 @@ import { AccessToken, CharacterUpdate, Env, PlanetWithInfo } 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 { runWebhookChecks } from "@/utils/webhookService";
|
||||||
|
import { WebhookConfig } from "@/types/webhook";
|
||||||
|
import { cleanupOldWebhookStates } from "@/utils/webhookTracker";
|
||||||
|
import { planetCalculations } from "@/planets";
|
||||||
|
|
||||||
// Add batch processing utility
|
// Add batch processing utility
|
||||||
const processInBatches = async <T, R>(
|
const processInBatches = async <T, R>(
|
||||||
@@ -47,6 +51,13 @@ 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: true, // Enable by default for testing
|
||||||
|
expiryThreshold: 'P12H',
|
||||||
|
storageWarningThreshold: 85,
|
||||||
|
storageCriticalThreshold: 100
|
||||||
|
});
|
||||||
|
const [webhookServerEnabled, setWebhookServerEnabled] = useState(false);
|
||||||
|
|
||||||
const [colors, setColors] = useState<ColorSelectionType>(defaultColors);
|
const [colors, setColors] = useState<ColorSelectionType>(defaultColors);
|
||||||
const [alertMode, setAlertMode] = useState(false);
|
const [alertMode, setAlertMode] = useState(false);
|
||||||
@@ -109,6 +120,7 @@ const Home = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const initializeCharacters = useCallback((): AccessToken[] => {
|
const initializeCharacters = useCallback((): AccessToken[] => {
|
||||||
|
if (typeof window === 'undefined') return [];
|
||||||
const localStorageCharacters = localStorage.getItem("characters");
|
const localStorageCharacters = localStorage.getItem("characters");
|
||||||
if (localStorageCharacters) {
|
if (localStorageCharacters) {
|
||||||
const characterArray: AccessToken[] = JSON.parse(localStorageCharacters);
|
const characterArray: AccessToken[] = JSON.parse(localStorageCharacters);
|
||||||
@@ -133,6 +145,27 @@ const Home = () => {
|
|||||||
infoUniverse: await getPlanetUniverse(p),
|
infoUniverse: await getPlanetUniverse(p),
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Run webhook checks for each planet if webhooks are enabled
|
||||||
|
if (webhookConfig.enabled) {
|
||||||
|
console.log(`🔍 Running webhook checks for ${c.character.name} (${planetsWithInfo.length} planets)`);
|
||||||
|
for (const planet of planetsWithInfo) {
|
||||||
|
try {
|
||||||
|
const calculations = planetCalculations(planet);
|
||||||
|
await runWebhookChecks(
|
||||||
|
c,
|
||||||
|
planet,
|
||||||
|
calculations.extractors,
|
||||||
|
webhookConfig
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Webhook check failed for planet:', planet.infoUniverse.name, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('🔕 Webhooks disabled, skipping checks');
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...c,
|
...c,
|
||||||
planets: planetsWithInfo,
|
planets: planetsWithInfo,
|
||||||
@@ -140,7 +173,9 @@ const Home = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const saveCharacters = (characters: AccessToken[]): AccessToken[] => {
|
const saveCharacters = (characters: AccessToken[]): AccessToken[] => {
|
||||||
localStorage.setItem("characters", JSON.stringify(characters));
|
if (typeof window !== 'undefined') {
|
||||||
|
localStorage.setItem("characters", JSON.stringify(characters));
|
||||||
|
}
|
||||||
return characters;
|
return characters;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -202,65 +237,122 @@ const Home = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const storedCompactMode = localStorage.getItem("compactMode");
|
if (typeof window !== 'undefined') {
|
||||||
if (!storedCompactMode) return;
|
const storedCompactMode = localStorage.getItem("compactMode");
|
||||||
storedCompactMode === "true" ? setCompactMode(true) : false;
|
if (storedCompactMode) {
|
||||||
}, []);
|
setCompactMode(storedCompactMode === "true");
|
||||||
|
}
|
||||||
useEffect(() => {
|
|
||||||
const storedColors = localStorage.getItem("colors");
|
|
||||||
if (!storedColors) return;
|
|
||||||
setColors(JSON.parse(storedColors));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const storedAlertMode = localStorage.getItem("alertMode");
|
|
||||||
if (!storedAlertMode) return;
|
|
||||||
setAlertMode(JSON.parse(storedAlertMode));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const storedBalanceThreshold = localStorage.getItem("balanceThreshold");
|
|
||||||
if (storedBalanceThreshold) {
|
|
||||||
setBalanceThreshold(parseInt(storedBalanceThreshold));
|
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
localStorage.setItem("balanceThreshold", balanceThreshold.toString());
|
if (typeof window !== 'undefined') {
|
||||||
|
const storedColors = localStorage.getItem("colors");
|
||||||
|
if (storedColors) {
|
||||||
|
setColors(JSON.parse(storedColors));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
const storedAlertMode = localStorage.getItem("alertMode");
|
||||||
|
if (storedAlertMode) {
|
||||||
|
setAlertMode(JSON.parse(storedAlertMode));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
const storedBalanceThreshold = localStorage.getItem("balanceThreshold");
|
||||||
|
if (storedBalanceThreshold) {
|
||||||
|
setBalanceThreshold(parseInt(storedBalanceThreshold));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
localStorage.setItem("balanceThreshold", balanceThreshold.toString());
|
||||||
|
}
|
||||||
}, [balanceThreshold]);
|
}, [balanceThreshold]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
localStorage.setItem("compactMode", compactMode ? "true" : "false");
|
if (typeof window !== 'undefined') {
|
||||||
|
localStorage.setItem("compactMode", compactMode ? "true" : "false");
|
||||||
|
}
|
||||||
}, [compactMode]);
|
}, [compactMode]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
localStorage.setItem("alertMode", alertMode ? "true" : "false");
|
if (typeof window !== 'undefined') {
|
||||||
|
localStorage.setItem("alertMode", alertMode ? "true" : "false");
|
||||||
|
}
|
||||||
}, [alertMode]);
|
}, [alertMode]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
localStorage.setItem("colors", JSON.stringify(colors));
|
if (typeof window !== 'undefined') {
|
||||||
|
localStorage.setItem("colors", JSON.stringify(colors));
|
||||||
|
}
|
||||||
}, [colors]);
|
}, [colors]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const savedMode = localStorage.getItem('extractionTimeMode');
|
if (typeof window !== 'undefined') {
|
||||||
if (savedMode) {
|
const savedMode = localStorage.getItem('extractionTimeMode');
|
||||||
setExtractionTimeMode(savedMode === 'true');
|
if (savedMode) {
|
||||||
|
setExtractionTimeMode(savedMode === 'true');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
localStorage.setItem('extractionTimeMode', extractionTimeMode.toString());
|
if (typeof window !== 'undefined') {
|
||||||
|
localStorage.setItem('extractionTimeMode', extractionTimeMode.toString());
|
||||||
|
}
|
||||||
}, [extractionTimeMode]);
|
}, [extractionTimeMode]);
|
||||||
|
|
||||||
|
// Load webhook config from localStorage after hydration
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
const savedWebhookConfig = localStorage.getItem("webhookConfig");
|
||||||
|
if (savedWebhookConfig) {
|
||||||
|
try {
|
||||||
|
const config = JSON.parse(savedWebhookConfig);
|
||||||
|
setWebhookConfig(prev => ({ ...prev, ...config }));
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to parse webhook config from localStorage:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Save webhook config to localStorage
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
localStorage.setItem("webhookConfig", JSON.stringify(webhookConfig));
|
||||||
|
}
|
||||||
|
}, [webhookConfig]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
console.log('🔧 Initializing app...');
|
||||||
fetch("api/env")
|
fetch("api/env")
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then((env) => {
|
.then((env) => {
|
||||||
|
console.log('🌐 Environment loaded:', env);
|
||||||
setEnvironment({
|
setEnvironment({
|
||||||
EVE_SSO_CLIENT_ID: env.EVE_SSO_CLIENT_ID,
|
EVE_SSO_CLIENT_ID: env.EVE_SSO_CLIENT_ID,
|
||||||
EVE_SSO_CALLBACK_URL: env.EVE_SSO_CALLBACK_URL,
|
EVE_SSO_CALLBACK_URL: env.EVE_SSO_CALLBACK_URL,
|
||||||
});
|
});
|
||||||
|
setWebhookServerEnabled(env.WEBHOOK_ENABLED || false);
|
||||||
|
console.log('🔔 Webhook server enabled:', env.WEBHOOK_ENABLED);
|
||||||
|
// Only allow enabling webhooks if server supports it
|
||||||
|
if (!env.WEBHOOK_ENABLED && webhookConfig.enabled) {
|
||||||
|
console.log('⚠️ Disabling webhooks - server not configured');
|
||||||
|
setWebhookConfig(prev => ({
|
||||||
|
...prev,
|
||||||
|
enabled: false
|
||||||
|
}));
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.then(initializeCharacters)
|
.then(initializeCharacters)
|
||||||
.then(refreshSession)
|
.then(refreshSession)
|
||||||
@@ -289,6 +381,49 @@ const Home = () => {
|
|||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Regular webhook checks (every minute)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!webhookConfig.enabled) {
|
||||||
|
console.log('🔕 Webhooks disabled, skipping regular checks');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('⏰ Setting up regular webhook checks (every minute)');
|
||||||
|
|
||||||
|
const runRegularWebhookChecks = async () => {
|
||||||
|
console.log('🔄 Running regular webhook checks...');
|
||||||
|
const currentCharacters = initializeCharacters();
|
||||||
|
for (const character of currentCharacters) {
|
||||||
|
if (character.needsLogin || !character.planets) continue;
|
||||||
|
|
||||||
|
for (const planet of character.planets) {
|
||||||
|
try {
|
||||||
|
const calculations = planetCalculations(planet);
|
||||||
|
await runWebhookChecks(
|
||||||
|
character,
|
||||||
|
planet,
|
||||||
|
calculations.extractors,
|
||||||
|
webhookConfig
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Regular webhook check failed for planet:', planet.infoUniverse.name, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup old webhook states
|
||||||
|
cleanupOldWebhookStates();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Run immediately
|
||||||
|
runRegularWebhookChecks();
|
||||||
|
|
||||||
|
// Set up interval for every minute
|
||||||
|
const webhookInterval = setInterval(runRegularWebhookChecks, 60000);
|
||||||
|
|
||||||
|
return () => clearInterval(webhookInterval);
|
||||||
|
}, [webhookConfig.enabled]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SessionContext.Provider
|
<SessionContext.Provider
|
||||||
value={{
|
value={{
|
||||||
@@ -312,6 +447,9 @@ const Home = () => {
|
|||||||
setBalanceThreshold,
|
setBalanceThreshold,
|
||||||
showProductIcons,
|
showProductIcons,
|
||||||
setShowProductIcons,
|
setShowProductIcons,
|
||||||
|
webhookConfig,
|
||||||
|
setWebhookConfig,
|
||||||
|
webhookServerEnabled,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<CharacterContext.Provider
|
<CharacterContext.Provider
|
||||||
|
@@ -10,6 +10,7 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
|||||||
try {
|
try {
|
||||||
const EVE_SSO_CALLBACK_URL = process.env.EVE_SSO_CALLBACK_URL;
|
const EVE_SSO_CALLBACK_URL = process.env.EVE_SSO_CALLBACK_URL;
|
||||||
const EVE_SSO_CLIENT_ID = process.env.EVE_SSO_CLIENT_ID;
|
const EVE_SSO_CLIENT_ID = process.env.EVE_SSO_CLIENT_ID;
|
||||||
|
const WEBHOOK_ENABLED = !!process.env.WEBHOOK_URL;
|
||||||
|
|
||||||
if (!EVE_SSO_CALLBACK_URL || !EVE_SSO_CLIENT_ID) {
|
if (!EVE_SSO_CALLBACK_URL || !EVE_SSO_CLIENT_ID) {
|
||||||
logger.error({
|
logger.error({
|
||||||
@@ -27,11 +28,16 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
|||||||
event: 'env_request_success',
|
event: 'env_request_success',
|
||||||
vars: {
|
vars: {
|
||||||
hasCallbackUrl: true,
|
hasCallbackUrl: true,
|
||||||
hasClientId: true
|
hasClientId: true,
|
||||||
|
webhookEnabled: WEBHOOK_ENABLED
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return res.json({ EVE_SSO_CLIENT_ID, EVE_SSO_CALLBACK_URL });
|
return res.json({
|
||||||
|
EVE_SSO_CLIENT_ID,
|
||||||
|
EVE_SSO_CALLBACK_URL,
|
||||||
|
WEBHOOK_ENABLED
|
||||||
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error({
|
logger.error({
|
||||||
event: 'env_request_failed',
|
event: 'env_request_failed',
|
||||||
|
62
src/pages/api/webhook.ts
Normal file
62
src/pages/api/webhook.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||||
|
import { WebhookPayload } from '@/types/webhook';
|
||||||
|
|
||||||
|
export default async function handler(
|
||||||
|
req: NextApiRequest,
|
||||||
|
res: NextApiResponse
|
||||||
|
) {
|
||||||
|
if (req.method !== 'POST') {
|
||||||
|
return res.status(405).json({ error: 'Method not allowed' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload: WebhookPayload = req.body;
|
||||||
|
|
||||||
|
// Validate payload
|
||||||
|
if (!payload.type || !payload.message || !payload.characterName || !payload.planetName) {
|
||||||
|
return res.status(400).json({ error: 'Invalid payload' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const webhookUrl = process.env.WEBHOOK_URL;
|
||||||
|
|
||||||
|
if (!webhookUrl) {
|
||||||
|
// If no external webhook URL is configured, just log the payload for testing
|
||||||
|
console.log('🔔 Webhook payload (no external URL configured):', JSON.stringify(payload, null, 2));
|
||||||
|
return res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
message: 'Webhook logged to console (no external URL configured)',
|
||||||
|
payload
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send webhook to external URL
|
||||||
|
console.log('🔔 Sending webhook to external URL:', webhookUrl);
|
||||||
|
|
||||||
|
// Use simple format with all the details
|
||||||
|
const webhookPayload = {
|
||||||
|
content: `${payload.message}\n**Character:** ${payload.characterName}\n**Planet:** ${payload.planetName}\n**Time:** ${new Date(payload.timestamp).toLocaleString()}`
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('📤 Sending payload:', JSON.stringify(webhookPayload, null, 2));
|
||||||
|
|
||||||
|
const response = await fetch(webhookUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(webhookPayload),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
console.error('❌ Webhook failed:', response.status, response.statusText, errorText);
|
||||||
|
throw new Error(`Webhook failed with status ${response.status}: ${errorText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('✅ Webhook sent successfully to external URL');
|
||||||
|
res.status(200).json({ success: true, message: 'Webhook sent to external URL' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Webhook error:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to send webhook', details: (error as Error).message });
|
||||||
|
}
|
||||||
|
}
|
@@ -78,6 +78,7 @@ export interface CharacterUpdate {
|
|||||||
export interface Env {
|
export interface Env {
|
||||||
EVE_SSO_CALLBACK_URL: string;
|
EVE_SSO_CALLBACK_URL: string;
|
||||||
EVE_SSO_CLIENT_ID: string;
|
EVE_SSO_CLIENT_ID: string;
|
||||||
|
WEBHOOK_ENABLED?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EvePraisalResult {
|
export interface EvePraisalResult {
|
||||||
|
21
src/types/webhook.ts
Normal file
21
src/types/webhook.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
export interface WebhookConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
expiryThreshold: string; // ISO 8601 duration format
|
||||||
|
storageWarningThreshold: number; // percentage
|
||||||
|
storageCriticalThreshold: number; // percentage
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WebhookPayload {
|
||||||
|
type: 'extractor_expiring' | 'extractor_expired' | 'storage_almost_full' | 'storage_full' | 'launchpad_almost_full' | 'launchpad_full';
|
||||||
|
message: string;
|
||||||
|
characterName: string;
|
||||||
|
planetName: string;
|
||||||
|
details?: {
|
||||||
|
extractorType?: string;
|
||||||
|
hoursRemaining?: number;
|
||||||
|
storageUsed?: number;
|
||||||
|
storageCapacity?: number;
|
||||||
|
fillPercentage?: number;
|
||||||
|
};
|
||||||
|
timestamp: string;
|
||||||
|
}
|
226
src/utils/webhookService.ts
Normal file
226
src/utils/webhookService.ts
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
import { DateTime, Duration } from 'luxon';
|
||||||
|
import { Pin, PlanetWithInfo, AccessToken } from '@/types';
|
||||||
|
import { StorageInfo } from '@/types/planet';
|
||||||
|
import { WebhookConfig, WebhookPayload } from '@/types/webhook';
|
||||||
|
import { STORAGE_IDS, LAUNCHPAD_IDS, PI_TYPES_MAP, STORAGE_CAPACITIES, PI_PRODUCT_VOLUMES } from '@/const';
|
||||||
|
import { shouldSendWebhook, markWebhookSent } from './webhookTracker';
|
||||||
|
|
||||||
|
const sendWebhook = async (payload: WebhookPayload): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
console.log('🔔 Sending webhook:', payload);
|
||||||
|
const response = await fetch('/api/webhook', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const result = await response.json();
|
||||||
|
console.log('✅ Webhook sent successfully:', result.message);
|
||||||
|
} else {
|
||||||
|
const error = await response.text();
|
||||||
|
console.log('❌ Webhook failed:', response.status, response.statusText, error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.ok;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Failed to send webhook:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const checkExtractorExpiry = async (
|
||||||
|
character: AccessToken,
|
||||||
|
planet: PlanetWithInfo,
|
||||||
|
extractors: Pin[],
|
||||||
|
config: WebhookConfig
|
||||||
|
): Promise<void> => {
|
||||||
|
if (!config.enabled) return;
|
||||||
|
|
||||||
|
if (extractors.length === 0) {
|
||||||
|
console.log(`📊 No extractors found on ${planet.infoUniverse.name}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`⏰ Checking ${extractors.length} extractors on ${planet.infoUniverse.name}`);
|
||||||
|
const now = DateTime.now();
|
||||||
|
const expiryThreshold = Duration.fromISO(config.expiryThreshold);
|
||||||
|
|
||||||
|
for (const extractor of extractors) {
|
||||||
|
if (!extractor.expiry_time || !extractor.extractor_details?.product_type_id) continue;
|
||||||
|
|
||||||
|
const expiryTime = DateTime.fromISO(extractor.expiry_time);
|
||||||
|
const timeUntilExpiry = expiryTime.diff(now);
|
||||||
|
const hoursRemaining = timeUntilExpiry.as('hours');
|
||||||
|
|
||||||
|
const productType = PI_TYPES_MAP[extractor.extractor_details.product_type_id];
|
||||||
|
const extractorType = productType?.name || `Type ${extractor.extractor_details.product_type_id}`;
|
||||||
|
|
||||||
|
// Check if extractor has expired
|
||||||
|
if (expiryTime <= now) {
|
||||||
|
console.log(`⏰ Extractor expired: ${extractorType} on ${planet.infoUniverse.name}`);
|
||||||
|
const event = 'done';
|
||||||
|
if (shouldSendWebhook(character.character.characterId, planet.planet_id, extractor.expiry_time, event)) {
|
||||||
|
const payload: WebhookPayload = {
|
||||||
|
type: 'extractor_expired',
|
||||||
|
message: `Extractor producing ${extractorType} has expired on ${planet.infoUniverse.name}`,
|
||||||
|
characterName: character.character.name,
|
||||||
|
planetName: planet.infoUniverse.name,
|
||||||
|
details: {
|
||||||
|
extractorType,
|
||||||
|
hoursRemaining: 0
|
||||||
|
},
|
||||||
|
timestamp: now.toISO()
|
||||||
|
};
|
||||||
|
|
||||||
|
if (await sendWebhook(payload)) {
|
||||||
|
markWebhookSent(character.character.characterId, planet.planet_id, extractor.expiry_time, event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Check if extractor is about to expire
|
||||||
|
else if (timeUntilExpiry <= expiryThreshold) {
|
||||||
|
console.log(`⚠️ Extractor expiring soon: ${extractorType} on ${planet.infoUniverse.name} (${hoursRemaining.toFixed(1)}h remaining)`);
|
||||||
|
const event = 'nearly done';
|
||||||
|
if (shouldSendWebhook(character.character.characterId, planet.planet_id, extractor.expiry_time, event)) {
|
||||||
|
const payload: WebhookPayload = {
|
||||||
|
type: 'extractor_expiring',
|
||||||
|
message: `Extractor producing ${extractorType} will expire in ${hoursRemaining.toFixed(1)} hours on ${planet.infoUniverse.name}`,
|
||||||
|
characterName: character.character.name,
|
||||||
|
planetName: planet.infoUniverse.name,
|
||||||
|
details: {
|
||||||
|
extractorType,
|
||||||
|
hoursRemaining: Math.max(0, hoursRemaining)
|
||||||
|
},
|
||||||
|
timestamp: now.toISO()
|
||||||
|
};
|
||||||
|
|
||||||
|
if (await sendWebhook(payload)) {
|
||||||
|
markWebhookSent(character.character.characterId, planet.planet_id, extractor.expiry_time, event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const checkStorageCapacity = async (
|
||||||
|
character: AccessToken,
|
||||||
|
planet: PlanetWithInfo,
|
||||||
|
storageInfo: StorageInfo[],
|
||||||
|
config: WebhookConfig
|
||||||
|
): Promise<void> => {
|
||||||
|
if (!config.enabled) return;
|
||||||
|
|
||||||
|
if (storageInfo.length === 0) {
|
||||||
|
console.log(`📦 No storage facilities found on ${planet.infoUniverse.name}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`📦 Checking ${storageInfo.length} storage facilities on ${planet.infoUniverse.name}`);
|
||||||
|
const now = DateTime.now();
|
||||||
|
|
||||||
|
for (const storage of storageInfo) {
|
||||||
|
const fillPercentage = (storage.used / storage.capacity) * 100;
|
||||||
|
const isLaunchpad = LAUNCHPAD_IDS.includes(storage.type_id);
|
||||||
|
const storageTypeName = PI_TYPES_MAP[storage.type_id]?.name || `Storage ${storage.type_id}`;
|
||||||
|
|
||||||
|
// Check for critical (100%) storage
|
||||||
|
if (fillPercentage >= config.storageCriticalThreshold) {
|
||||||
|
const webhookType = isLaunchpad ? 'launchpad_full' : 'storage_full';
|
||||||
|
console.log(`🚨 ${storageTypeName} is ${fillPercentage.toFixed(1)}% full on ${planet.infoUniverse.name}`);
|
||||||
|
|
||||||
|
if (shouldSendWebhook(character.character.characterId, planet.planet_id, webhookType, undefined, storage.type_id)) {
|
||||||
|
const payload: WebhookPayload = {
|
||||||
|
type: webhookType,
|
||||||
|
message: `${storageTypeName} is ${fillPercentage.toFixed(1)}% full on ${planet.infoUniverse.name}`,
|
||||||
|
characterName: character.character.name,
|
||||||
|
planetName: planet.infoUniverse.name,
|
||||||
|
details: {
|
||||||
|
storageUsed: storage.used,
|
||||||
|
storageCapacity: storage.capacity,
|
||||||
|
fillPercentage: fillPercentage
|
||||||
|
},
|
||||||
|
timestamp: now.toISO()
|
||||||
|
};
|
||||||
|
|
||||||
|
if (await sendWebhook(payload)) {
|
||||||
|
markWebhookSent(character.character.characterId, planet.planet_id, webhookType, undefined, storage.type_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Check for warning threshold
|
||||||
|
else if (fillPercentage >= config.storageWarningThreshold) {
|
||||||
|
const webhookType = isLaunchpad ? 'launchpad_almost_full' : 'storage_almost_full';
|
||||||
|
console.log(`⚠️ ${storageTypeName} is ${fillPercentage.toFixed(1)}% full on ${planet.infoUniverse.name}`);
|
||||||
|
|
||||||
|
if (shouldSendWebhook(character.character.characterId, planet.planet_id, webhookType, undefined, storage.type_id)) {
|
||||||
|
const payload: WebhookPayload = {
|
||||||
|
type: webhookType,
|
||||||
|
message: `${storageTypeName} is ${fillPercentage.toFixed(1)}% full on ${planet.infoUniverse.name}`,
|
||||||
|
characterName: character.character.name,
|
||||||
|
planetName: planet.infoUniverse.name,
|
||||||
|
details: {
|
||||||
|
storageUsed: storage.used,
|
||||||
|
storageCapacity: storage.capacity,
|
||||||
|
fillPercentage: fillPercentage
|
||||||
|
},
|
||||||
|
timestamp: now.toISO()
|
||||||
|
};
|
||||||
|
|
||||||
|
if (await sendWebhook(payload)) {
|
||||||
|
markWebhookSent(character.character.characterId, planet.planet_id, webhookType, undefined, storage.type_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const calculateStorageInfo = (planet: PlanetWithInfo): StorageInfo[] => {
|
||||||
|
const storageFacilities = planet.info.pins.filter((pin: Pin) =>
|
||||||
|
STORAGE_IDS().some(storage => storage.type_id === pin.type_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
return storageFacilities.map((storage: Pin): StorageInfo => {
|
||||||
|
const storageType = PI_TYPES_MAP[storage.type_id]?.name || 'Unknown';
|
||||||
|
const storageCapacity = STORAGE_CAPACITIES[storage.type_id] || 0;
|
||||||
|
|
||||||
|
const totalVolume = (storage.contents || [])
|
||||||
|
.reduce((sum: number, item) => {
|
||||||
|
const volume = PI_PRODUCT_VOLUMES[item.type_id] || 0;
|
||||||
|
return sum + (item.amount * volume);
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
const fillRate = storageCapacity > 0 ? (totalVolume / storageCapacity) * 100 : 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: storageType,
|
||||||
|
type_id: storage.type_id,
|
||||||
|
capacity: storageCapacity,
|
||||||
|
used: totalVolume,
|
||||||
|
fillRate: fillRate,
|
||||||
|
value: 0 // We don't need value for webhook checks
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const runWebhookChecks = async (
|
||||||
|
character: AccessToken,
|
||||||
|
planet: PlanetWithInfo,
|
||||||
|
extractors: Pin[],
|
||||||
|
config: WebhookConfig
|
||||||
|
): Promise<void> => {
|
||||||
|
if (!config.enabled) {
|
||||||
|
console.log('🔕 Webhooks disabled, skipping checks');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`🔍 Running webhook checks for ${character.character.name} - ${planet.infoUniverse.name}`);
|
||||||
|
const storageInfo = calculateStorageInfo(planet);
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
checkExtractorExpiry(character, planet, extractors, config),
|
||||||
|
checkStorageCapacity(character, planet, storageInfo, config)
|
||||||
|
]);
|
||||||
|
};
|
122
src/utils/webhookTracker.ts
Normal file
122
src/utils/webhookTracker.ts
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
|
||||||
|
interface WebhookState {
|
||||||
|
sent: boolean;
|
||||||
|
lastSent: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create hash for unique events: character-planet-extractorEndTimestamp-event
|
||||||
|
export const createWebhookHash = (
|
||||||
|
characterId: number,
|
||||||
|
planetId: number,
|
||||||
|
extractorEndTimestamp: string,
|
||||||
|
event: string
|
||||||
|
): string => {
|
||||||
|
return `${characterId}-${planetId}-${extractorEndTimestamp}-${event}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get webhook state from localStorage
|
||||||
|
const getWebhookState = (hash: string): WebhookState => {
|
||||||
|
if (typeof window === 'undefined') return { sent: false, lastSent: 0 };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(`webhook_${hash}`);
|
||||||
|
if (stored) {
|
||||||
|
return JSON.parse(stored);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to parse webhook state from localStorage:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { sent: false, lastSent: 0 };
|
||||||
|
};
|
||||||
|
|
||||||
|
// Set webhook state in localStorage
|
||||||
|
const setWebhookState = (hash: string, state: WebhookState): void => {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
localStorage.setItem(`webhook_${hash}`, JSON.stringify(state));
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to save webhook state to localStorage:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const shouldSendWebhook = (
|
||||||
|
characterId: number,
|
||||||
|
planetId: number,
|
||||||
|
extractorEndTimestamp: string,
|
||||||
|
event: string
|
||||||
|
): boolean => {
|
||||||
|
const hash = createWebhookHash(characterId, planetId, extractorEndTimestamp, event);
|
||||||
|
const state = getWebhookState(hash);
|
||||||
|
|
||||||
|
return !state.sent;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Storage webhook tracking with 1-hour cooldown
|
||||||
|
export const shouldSendStorageWebhook = (
|
||||||
|
characterId: number,
|
||||||
|
planetId: number,
|
||||||
|
storageTypeId: number,
|
||||||
|
webhookType: string
|
||||||
|
): boolean => {
|
||||||
|
const hash = `storage-${characterId}-${planetId}-${storageTypeId}-${webhookType}`;
|
||||||
|
const state = getWebhookState(hash);
|
||||||
|
|
||||||
|
if (!state.sent) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if 1 hour has passed since last notification
|
||||||
|
const oneHourAgo = Date.now() - (60 * 60 * 1000);
|
||||||
|
return state.lastSent < oneHourAgo;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const markStorageWebhookSent = (
|
||||||
|
characterId: number,
|
||||||
|
planetId: number,
|
||||||
|
storageTypeId: number,
|
||||||
|
webhookType: string
|
||||||
|
): void => {
|
||||||
|
const hash = `storage-${characterId}-${planetId}-${storageTypeId}-${webhookType}`;
|
||||||
|
setWebhookState(hash, { sent: true, lastSent: Date.now() });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const markWebhookSent = (
|
||||||
|
characterId: number,
|
||||||
|
planetId: number,
|
||||||
|
extractorEndTimestamp: string,
|
||||||
|
event: string
|
||||||
|
): void => {
|
||||||
|
const hash = createWebhookHash(characterId, planetId, extractorEndTimestamp, event);
|
||||||
|
setWebhookState(hash, { sent: true, lastSent: Date.now() });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Cleanup old webhook states (older than 7 days)
|
||||||
|
export const cleanupOldWebhookStates = (): void => {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
|
||||||
|
const cutoff = Date.now() - (7 * 24 * 60 * 60 * 1000); // 7 days ago
|
||||||
|
|
||||||
|
try {
|
||||||
|
const keysToRemove: string[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < localStorage.length; i++) {
|
||||||
|
const key = localStorage.key(i);
|
||||||
|
if (key && key.startsWith('webhook_')) {
|
||||||
|
const state = JSON.parse(localStorage.getItem(key) || '{}');
|
||||||
|
if (state.lastSent && state.lastSent < cutoff) {
|
||||||
|
keysToRemove.push(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
keysToRemove.forEach(key => localStorage.removeItem(key));
|
||||||
|
|
||||||
|
if (keysToRemove.length > 0) {
|
||||||
|
console.log(`🧹 Cleaned up ${keysToRemove.length} old webhook states`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to cleanup old webhook states:', error);
|
||||||
|
}
|
||||||
|
};
|
Reference in New Issue
Block a user