Compare commits

..

1 Commits

11 changed files with 371 additions and 695 deletions

View File

@@ -66,9 +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 Configuration (optional)
WEBHOOK_URL=Discord webhook URL for notifications
WEBHOOK_URL=Discord webhook URL for notifications (optional)
```
## Run locally
@@ -87,44 +85,11 @@ The application supports webhook notifications for various PI events:
- **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"
}
```
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.

View File

@@ -15,14 +15,13 @@ import {
Box,
FormControlLabel,
Checkbox,
Divider,
} from "@mui/material";
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, webhookConfig, setWebhookConfig, webhookServerEnabled } = useContext(SessionContext);
const { balanceThreshold, setBalanceThreshold, showProductIcons, setShowProductIcons, webhookConfig, setWebhookConfig } = useContext(SessionContext);
const [open, setOpen] = useState(false);
const handleClickOpen = () => {
@@ -53,28 +52,40 @@ export const SettingsButton = () => {
};
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 }));
setWebhookConfig({
...webhookConfig,
enabled: event.target.checked
});
};
const handleExpiryThresholdChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setWebhookConfig(prev => ({ ...prev, expiryThreshold: event.target.value }));
};
const handleStorageWarningChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const handleExtractorWarningHoursChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const value = parseInt(event.target.value);
if (!isNaN(value) && value >= 0 && value <= 100) {
setWebhookConfig(prev => ({ ...prev, storageWarningThreshold: value }));
if (!isNaN(value) && value >= 0 && value <= 168) { // Max 1 week
setWebhookConfig({
...webhookConfig,
extractorWarningHours: value
});
}
};
const handleStorageCriticalChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const handleStorageWarningPercentChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const value = parseInt(event.target.value);
if (!isNaN(value) && value >= 0 && value <= 100) {
setWebhookConfig(prev => ({ ...prev, storageCriticalThreshold: value }));
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
});
}
};
@@ -120,66 +131,58 @@ export const SettingsButton = () => {
error={balanceThreshold < 0 || balanceThreshold > 100000}
/>
</Box>
<Box sx={{ mt: 3 }}>
<Divider sx={{ mb: 2 }} />
<Box sx={{ mt: 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}
checked={webhookConfig.enabled}
onChange={handleWebhookEnabledChange}
disabled={!webhookServerEnabled}
/>
}
label="Enable webhook notifications"
/>
{webhookConfig.enabled && webhookServerEnabled && (
{webhookConfig.enabled && (
<>
<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)"
/>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Webhook URL is configured via the WEBHOOK_URL environment variable.
</Typography>
<TextField
type="number"
label="Storage Warning Threshold (%)"
value={webhookConfig.storageWarningThreshold}
onChange={handleStorageWarningChange}
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="Alert when storage reaches this percentage (0-100)"
helperText="Storage capacity percentage to trigger warning (0-100)"
error={webhookConfig.storageWarningPercent < 0 || webhookConfig.storageWarningPercent > 100}
/>
<TextField
type="number"
label="Storage Critical Threshold (%)"
value={webhookConfig.storageCriticalThreshold}
onChange={handleStorageCriticalChange}
label="Launchpad Warning %"
value={webhookConfig.launchpadWarningPercent}
onChange={handleLaunchpadWarningPercentChange}
fullWidth
margin="normal"
inputProps={{ min: 0, max: 100 }}
helperText="Alert when storage is completely full (usually 100)"
helperText="Launchpad capacity percentage to trigger warning (0-100)"
error={webhookConfig.launchpadWarningPercent < 0 || webhookConfig.launchpadWarningPercent > 100}
/>
</>
)}
</Box>
<Box sx={{ mt: 3 }}>
<Divider sx={{ mb: 2 }} />
<Typography variant="subtitle1">Colors</Typography>
</Box>
{Object.keys(colors).map((key) => {
return (
<div key={`color-row-${key}`}>

View File

@@ -1,6 +1,5 @@
import { EvePraisalResult } from "@/eve-praisal";
import { AccessToken, CharacterUpdate, PlanetConfig } from "@/types";
import { WebhookConfig } from "@/types/webhook";
import { AccessToken, CharacterUpdate, PlanetConfig, WebhookConfig } from "@/types";
import { Dispatch, SetStateAction, createContext } from "react";
export const CharacterContext = createContext<{
@@ -43,8 +42,7 @@ export const SessionContext = createContext<{
showProductIcons: boolean;
setShowProductIcons: (show: boolean) => void;
webhookConfig: WebhookConfig;
setWebhookConfig: Dispatch<SetStateAction<WebhookConfig>>;
webhookServerEnabled: boolean;
setWebhookConfig: (config: WebhookConfig) => void;
}>({
sessionReady: false,
refreshSession: () => {},
@@ -76,12 +74,11 @@ export const SessionContext = createContext<{
setShowProductIcons: () => {},
webhookConfig: {
enabled: false,
expiryThreshold: 'P12H',
storageWarningThreshold: 85,
storageCriticalThreshold: 100
extractorWarningHours: 2,
storageWarningPercent: 85,
launchpadWarningPercent: 90,
},
setWebhookConfig: () => {},
webhookServerEnabled: false,
});
export type ColorSelectionType = {

View File

@@ -4,7 +4,7 @@ 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 {
@@ -18,10 +18,7 @@ import { useSearchParams } from "next/navigation";
import { EvePraisalResult, fetchAllPrices } from "@/eve-praisal";
import { getPlanet, getPlanetUniverse, getPlanets } from "@/planets";
import { PlanetConfig } from "@/types";
import { runWebhookChecks } from "@/utils/webhookService";
import { WebhookConfig } from "@/types/webhook";
import { cleanupOldWebhookStates } from "@/utils/webhookTracker";
import { planetCalculations } from "@/planets";
import { notificationService } from "@/utils/notificationService";
// Add batch processing utility
const processInBatches = async <T, R>(
@@ -52,12 +49,11 @@ const Home = () => {
const [showProductIcons, setShowProductIcons] = 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
enabled: false,
extractorWarningHours: 2,
storageWarningPercent: 85,
launchpadWarningPercent: 90,
});
const [webhookServerEnabled, setWebhookServerEnabled] = useState(false);
const [colors, setColors] = useState<ColorSelectionType>(defaultColors);
const [alertMode, setAlertMode] = useState(false);
@@ -120,7 +116,6 @@ const Home = () => {
};
const initializeCharacters = useCallback((): AccessToken[] => {
if (typeof window === 'undefined') return [];
const localStorageCharacters = localStorage.getItem("characters");
if (localStorageCharacters) {
const characterArray: AccessToken[] = JSON.parse(localStorageCharacters);
@@ -145,27 +140,6 @@ const Home = () => {
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 {
...c,
planets: planetsWithInfo,
@@ -173,9 +147,7 @@ const Home = () => {
});
const saveCharacters = (characters: AccessToken[]): AccessToken[] => {
if (typeof window !== 'undefined') {
localStorage.setItem("characters", JSON.stringify(characters));
}
return characters;
};
@@ -237,122 +209,76 @@ const Home = () => {
};
useEffect(() => {
if (typeof window !== 'undefined') {
const storedCompactMode = localStorage.getItem("compactMode");
if (storedCompactMode) {
setCompactMode(storedCompactMode === "true");
}
}
if (!storedCompactMode) return;
storedCompactMode === "true" ? setCompactMode(true) : false;
}, []);
useEffect(() => {
if (typeof window !== 'undefined') {
const storedColors = localStorage.getItem("colors");
if (storedColors) {
if (!storedColors) return;
setColors(JSON.parse(storedColors));
}
}
}, []);
useEffect(() => {
if (typeof window !== 'undefined') {
const storedAlertMode = localStorage.getItem("alertMode");
if (storedAlertMode) {
if (!storedAlertMode) return;
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]);
useEffect(() => {
if (typeof window !== 'undefined') {
localStorage.setItem("compactMode", compactMode ? "true" : "false");
}
}, [compactMode]);
useEffect(() => {
if (typeof window !== 'undefined') {
localStorage.setItem("alertMode", alertMode ? "true" : "false");
}
}, [alertMode]);
useEffect(() => {
if (typeof window !== 'undefined') {
localStorage.setItem("colors", JSON.stringify(colors));
}
}, [colors]);
useEffect(() => {
if (typeof window !== 'undefined') {
const savedMode = localStorage.getItem('extractionTimeMode');
if (savedMode) {
setExtractionTimeMode(savedMode === 'true');
}
}
}, []);
useEffect(() => {
if (typeof window !== 'undefined') {
localStorage.setItem('extractionTimeMode', extractionTimeMode.toString());
}
}, [extractionTimeMode]);
// Load webhook config from localStorage after hydration
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);
}
}
const storedWebhookConfig = localStorage.getItem("webhookConfig");
if (storedWebhookConfig) {
setWebhookConfig(JSON.parse(storedWebhookConfig));
}
}, []);
// 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")
.then((r) => r.json())
.then((env) => {
console.log('🌐 Environment loaded:', env);
setEnvironment({
EVE_SSO_CLIENT_ID: env.EVE_SSO_CLIENT_ID,
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(refreshSession)
@@ -376,53 +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);
});
// 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]);
}, [webhookConfig]);
return (
<SessionContext.Provider
@@ -449,7 +336,6 @@ const Home = () => {
setShowProductIcons,
webhookConfig,
setWebhookConfig,
webhookServerEnabled,
}}
>
<CharacterContext.Provider

View File

@@ -10,7 +10,6 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
try {
const EVE_SSO_CALLBACK_URL = process.env.EVE_SSO_CALLBACK_URL;
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) {
logger.error({
@@ -28,16 +27,11 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
event: 'env_request_success',
vars: {
hasCallbackUrl: true,
hasClientId: true,
webhookEnabled: WEBHOOK_ENABLED
hasClientId: true
}
});
return res.json({
EVE_SSO_CLIENT_ID,
EVE_SSO_CALLBACK_URL,
WEBHOOK_ENABLED
});
return res.json({ EVE_SSO_CLIENT_ID, EVE_SSO_CALLBACK_URL });
} catch (e) {
logger.error({
event: 'env_request_failed',

View File

@@ -1,62 +1,67 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { WebhookPayload } from '@/types/webhook';
import { NextApiRequest, NextApiResponse } from "next";
import logger from "@/utils/logger";
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
if (req.method !== 'POST') {
return res.status(405).json({ error: 'Method not allowed' });
}
interface WebhookPayload {
content: string;
}
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method === "POST") {
try {
const payload: WebhookPayload = req.body;
const { content }: { content: string } = req.body;
// Validate payload
if (!payload.type || !payload.message || !payload.characterName || !payload.planetName) {
return res.status(400).json({ error: 'Invalid payload' });
if (!content) {
return res.status(400).json({ error: "Content is required" });
}
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
});
return res.status(500).json({ error: "Webhook URL not configured" });
}
// Send webhook to external URL
console.log('🔔 Sending webhook to external URL:', webhookUrl);
const payload: WebhookPayload = { content };
// 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));
logger.info({
event: 'webhook_send_start',
url: webhookUrl.replace(/\/[^\/]*$/, '/***'), // Mask the webhook token
contentLength: content.length
});
const response = await fetch(webhookUrl, {
method: 'POST',
method: "POST",
headers: {
'Content-Type': 'application/json',
"Content-Type": "application/json",
},
body: JSON.stringify(webhookPayload),
body: JSON.stringify(payload),
});
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}`);
throw new Error(`Webhook request failed with status ${response.status}`);
}
console.log('✅ Webhook sent successfully to external URL');
res.status(200).json({ success: true, message: 'Webhook sent to external URL' });
logger.info({
event: 'webhook_send_success',
url: webhookUrl.replace(/\/[^\/]*$/, '/***'),
status: response.status
});
return res.json({ success: true });
} catch (error) {
console.error('Webhook error:', error);
res.status(500).json({ error: 'Failed to send webhook', details: (error as Error).message });
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;

View File

@@ -78,7 +78,6 @@ export interface CharacterUpdate {
export interface Env {
EVE_SSO_CALLBACK_URL: string;
EVE_SSO_CLIENT_ID: string;
WEBHOOK_ENABLED?: boolean;
}
export interface EvePraisalResult {
@@ -124,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<string>;
extractorWarning: Set<string>;
storageFull: Set<string>;
storageWarning: Set<string>;
launchpadFull: Set<string>;
launchpadWarning: Set<string>;
}

View File

@@ -1,21 +0,0 @@
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;
}

View 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();

View File

@@ -1,226 +0,0 @@
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)
]);
};

View File

@@ -1,122 +0,0 @@
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);
}
};