Add webhook notification functionality with configuration options

This commit is contained in:
2025-09-21 16:00:45 +02:00
parent 46289d4667
commit b56a950f04
10 changed files with 750 additions and 57 deletions

View File

@@ -66,7 +66,9 @@ You will need these env variables from the application settings:
EVE_SSO_CLIENT_ID=Client ID
EVE_SSO_SECRET=Secret Key
EVE_SSO_CALLBACK_URL=Callback URL (This should be the domain you are hosting at or if run locally it should be http://localhost:3000)
WEBHOOK_URL=Discord webhook URL for notifications (optional)
# Webhook Configuration (optional)
WEBHOOK_URL=Discord webhook URL for notifications
```
## Run locally
@@ -85,11 +87,44 @@ 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
2. Enable webhook notifications in the Settings dialog
3. Configure warning thresholds for extractors, storage, and launch pads
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.

View File

@@ -15,13 +15,14 @@ import {
Box,
FormControlLabel,
Checkbox,
Divider,
} from "@mui/material";
import { ColorChangeHandler, ColorResult, CompactPicker } from "react-color";
import { ColorResult, CompactPicker } from "react-color";
import React, { useState, useContext } from "react";
export const SettingsButton = () => {
const { colors, setColors } = useContext(ColorContext);
const { balanceThreshold, setBalanceThreshold, showProductIcons, setShowProductIcons } = useContext(SessionContext);
const { balanceThreshold, setBalanceThreshold, showProductIcons, setShowProductIcons, webhookConfig, setWebhookConfig, webhookServerEnabled } = useContext(SessionContext);
const [open, setOpen] = useState(false);
const handleClickOpen = () => {
@@ -51,6 +52,32 @@ export const SettingsButton = () => {
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 (
<Tooltip title="Toggle settings dialog">
<>
@@ -93,6 +120,66 @@ export const SettingsButton = () => {
error={balanceThreshold < 0 || balanceThreshold > 100000}
/>
</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) => {
return (
<div key={`color-row-${key}`}>

View File

@@ -1,5 +1,6 @@
import { EvePraisalResult } from "@/eve-praisal";
import { AccessToken, CharacterUpdate, PlanetConfig } from "@/types";
import { WebhookConfig } from "@/types/webhook";
import { Dispatch, SetStateAction, createContext } from "react";
export const CharacterContext = createContext<{
@@ -41,6 +42,9 @@ export const SessionContext = createContext<{
setBalanceThreshold: Dispatch<SetStateAction<number>>;
showProductIcons: boolean;
setShowProductIcons: (show: boolean) => void;
webhookConfig: WebhookConfig;
setWebhookConfig: Dispatch<SetStateAction<WebhookConfig>>;
webhookServerEnabled: boolean;
}>({
sessionReady: false,
refreshSession: () => {},
@@ -70,6 +74,14 @@ export const SessionContext = createContext<{
setBalanceThreshold: () => {},
showProductIcons: false,
setShowProductIcons: () => {},
webhookConfig: {
enabled: false,
expiryThreshold: 'P12H',
storageWarningThreshold: 85,
storageCriticalThreshold: 100
},
setWebhookConfig: () => {},
webhookServerEnabled: false,
});
export type ColorSelectionType = {

View File

@@ -18,6 +18,10 @@ 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";
// Add batch processing utility
const processInBatches = async <T, R>(
@@ -47,6 +51,13 @@ const Home = () => {
const [balanceThreshold, setBalanceThreshold] = useState(1000);
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
});
const [webhookServerEnabled, setWebhookServerEnabled] = useState(false);
const [colors, setColors] = useState<ColorSelectionType>(defaultColors);
const [alertMode, setAlertMode] = useState(false);
@@ -109,6 +120,7 @@ const Home = () => {
};
const initializeCharacters = useCallback((): AccessToken[] => {
if (typeof window === 'undefined') return [];
const localStorageCharacters = localStorage.getItem("characters");
if (localStorageCharacters) {
const characterArray: AccessToken[] = JSON.parse(localStorageCharacters);
@@ -133,6 +145,27 @@ 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,
@@ -140,7 +173,9 @@ const Home = () => {
});
const saveCharacters = (characters: AccessToken[]): AccessToken[] => {
if (typeof window !== 'undefined') {
localStorage.setItem("characters", JSON.stringify(characters));
}
return characters;
};
@@ -202,65 +237,122 @@ const Home = () => {
};
useEffect(() => {
if (typeof window !== 'undefined') {
const storedCompactMode = localStorage.getItem("compactMode");
if (!storedCompactMode) return;
storedCompactMode === "true" ? setCompactMode(true) : false;
if (storedCompactMode) {
setCompactMode(storedCompactMode === "true");
}
}
}, []);
useEffect(() => {
if (typeof window !== 'undefined') {
const storedColors = localStorage.getItem("colors");
if (!storedColors) return;
if (storedColors) {
setColors(JSON.parse(storedColors));
}
}
}, []);
useEffect(() => {
if (typeof window !== 'undefined') {
const storedAlertMode = localStorage.getItem("alertMode");
if (!storedAlertMode) return;
if (storedAlertMode) {
setAlertMode(JSON.parse(storedAlertMode));
}
}
}, []);
useEffect(() => {
if (typeof window !== 'undefined') {
const storedBalanceThreshold = localStorage.getItem("balanceThreshold");
if (storedBalanceThreshold) {
setBalanceThreshold(parseInt(storedBalanceThreshold));
}
}, []);
useEffect(() => {
localStorage.setItem("balanceThreshold", balanceThreshold.toString());
}, [balanceThreshold]);
useEffect(() => {
localStorage.setItem("compactMode", compactMode ? "true" : "false");
}, [compactMode]);
useEffect(() => {
localStorage.setItem("alertMode", alertMode ? "true" : "false");
}, [alertMode]);
useEffect(() => {
localStorage.setItem("colors", JSON.stringify(colors));
}, [colors]);
useEffect(() => {
const savedMode = localStorage.getItem('extractionTimeMode');
if (savedMode) {
setExtractionTimeMode(savedMode === 'true');
}
}, []);
useEffect(() => {
localStorage.setItem('extractionTimeMode', extractionTimeMode.toString());
}, [extractionTimeMode]);
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);
}
}
}
}, []);
// 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)
@@ -289,6 +381,49 @@ const Home = () => {
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 (
<SessionContext.Provider
value={{
@@ -312,6 +447,9 @@ const Home = () => {
setBalanceThreshold,
showProductIcons,
setShowProductIcons,
webhookConfig,
setWebhookConfig,
webhookServerEnabled,
}}
>
<CharacterContext.Provider

View File

@@ -10,6 +10,7 @@ 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({
@@ -27,11 +28,16 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
event: 'env_request_success',
vars: {
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) {
logger.error({
event: 'env_request_failed',

62
src/pages/api/webhook.ts Normal file
View 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 as requested
const webhookPayload = {
content: payload.message
};
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 });
}
}

View File

@@ -78,6 +78,7 @@ export interface CharacterUpdate {
export interface Env {
EVE_SSO_CALLBACK_URL: string;
EVE_SSO_CLIENT_ID: string;
WEBHOOK_ENABLED?: boolean;
}
export interface EvePraisalResult {

21
src/types/webhook.ts Normal file
View 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;
}

228
src/utils/webhookService.ts Normal file
View File

@@ -0,0 +1,228 @@
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, resetExtractorWebhookStates } 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}`);
if (shouldSendWebhook(character.character.characterId, planet.planet_id, 'extractor_expired', extractor.pin_id)) {
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_expired', extractor.pin_id);
}
}
}
// 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)`);
if (shouldSendWebhook(character.character.characterId, planet.planet_id, 'extractor_expiring', extractor.pin_id)) {
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_expiring', extractor.pin_id);
}
}
}
// If extractor has been reset (duration is "full" again), reset webhook states
else if (timeUntilExpiry > expiryThreshold.plus({ hours: 1 })) {
resetExtractorWebhookStates(character.character.characterId, planet.planet_id, extractor.pin_id);
}
}
};
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)
]);
};

103
src/utils/webhookTracker.ts Normal file
View File

@@ -0,0 +1,103 @@
import { DateTime } from 'luxon';
export interface WebhookState {
lastSent: string;
type: string;
planetId: number;
characterId: number;
extractorPinId?: number;
storageTypeId?: number;
}
// In-memory storage for webhook states (in production, you might want to use localStorage or a database)
const webhookStates = new Map<string, WebhookState>();
export const generateWebhookKey = (
characterId: number,
planetId: number,
type: string,
extractorPinId?: number,
storageTypeId?: number
): string => {
const parts = [characterId, planetId, type];
if (extractorPinId !== undefined) parts.push(`extractor-${extractorPinId}`);
if (storageTypeId !== undefined) parts.push(`storage-${storageTypeId}`);
return parts.join('-');
};
export const shouldSendWebhook = (
characterId: number,
planetId: number,
type: string,
extractorPinId?: number,
storageTypeId?: number
): boolean => {
const key = generateWebhookKey(characterId, planetId, type, extractorPinId, storageTypeId);
const state = webhookStates.get(key);
if (!state) {
return true; // First time, send webhook
}
const lastSent = DateTime.fromISO(state.lastSent);
const now = DateTime.now();
// For expired extractors, resend every hour
if (type === 'extractor_expired') {
return now.diff(lastSent, 'hours').hours >= 1;
}
// For other alerts, resend every 4 hours
return now.diff(lastSent, 'hours').hours >= 4;
};
export const markWebhookSent = (
characterId: number,
planetId: number,
type: string,
extractorPinId?: number,
storageTypeId?: number
): void => {
const key = generateWebhookKey(characterId, planetId, type, extractorPinId, storageTypeId);
webhookStates.set(key, {
lastSent: DateTime.now().toISO(),
type,
planetId,
characterId,
extractorPinId,
storageTypeId
});
};
export const resetWebhookState = (
characterId: number,
planetId: number,
type: string,
extractorPinId?: number,
storageTypeId?: number
): void => {
const key = generateWebhookKey(characterId, planetId, type, extractorPinId, storageTypeId);
webhookStates.delete(key);
};
// Reset extractor webhook states when extractors are refreshed (duration is "full" again)
export const resetExtractorWebhookStates = (
characterId: number,
planetId: number,
extractorPinId: number
): void => {
resetWebhookState(characterId, planetId, 'extractor_expiring', extractorPinId);
resetWebhookState(characterId, planetId, 'extractor_expired', extractorPinId);
};
// Clear old webhook states (older than 7 days)
export const cleanupOldWebhookStates = (): void => {
const cutoff = DateTime.now().minus({ days: 7 });
for (const [key, state] of webhookStates.entries()) {
const lastSent = DateTime.fromISO(state.lastSent);
if (lastSent < cutoff) {
webhookStates.delete(key);
}
}
};