From b56a950f0484317bc6750e3b5b88caea228017d2 Mon Sep 17 00:00:00 2001 From: PhatPhuckDave Date: Sun, 21 Sep 2025 16:00:45 +0200 Subject: [PATCH] Add webhook notification functionality with configuration options --- README.md | 43 +++- .../components/Settings/SettingsButtons.tsx | 119 +++++++-- src/app/context/Context.tsx | 12 + src/app/page.tsx | 208 +++++++++++++--- src/pages/api/env.ts | 10 +- src/pages/api/webhook.ts | 62 +++++ src/types.ts | 1 + src/types/webhook.ts | 21 ++ src/utils/webhookService.ts | 228 ++++++++++++++++++ src/utils/webhookTracker.ts | 103 ++++++++ 10 files changed, 750 insertions(+), 57 deletions(-) create mode 100644 src/pages/api/webhook.ts create mode 100644 src/types/webhook.ts create mode 100644 src/utils/webhookService.ts create mode 100644 src/utils/webhookTracker.ts diff --git a/README.md b/README.md index c1e40a6..9bef3a2 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/src/app/components/Settings/SettingsButtons.tsx b/src/app/components/Settings/SettingsButtons.tsx index cc79a77..3fb0058 100644 --- a/src/app/components/Settings/SettingsButtons.tsx +++ b/src/app/components/Settings/SettingsButtons.tsx @@ -1,27 +1,28 @@ import { - ColorContext, - ColorSelectionType, - SessionContext, + ColorContext, + ColorSelectionType, + SessionContext, } from "@/app/context/Context"; import { - Button, - Dialog, - DialogActions, - DialogContent, - DialogTitle, - Tooltip, - Typography, - TextField, - Box, - FormControlLabel, - Checkbox, + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Tooltip, + Typography, + TextField, + Box, + FormControlLabel, + Checkbox, + 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) => { + 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) => { + setWebhookConfig(prev => ({ ...prev, expiryThreshold: event.target.value })); + }; + + const handleStorageWarningChange = (event: React.ChangeEvent) => { + const value = parseInt(event.target.value); + if (!isNaN(value) && value >= 0 && value <= 100) { + setWebhookConfig(prev => ({ ...prev, storageWarningThreshold: value })); + } + }; + + const handleStorageCriticalChange = (event: React.ChangeEvent) => { + const value = parseInt(event.target.value); + if (!isNaN(value) && value >= 0 && value <= 100) { + setWebhookConfig(prev => ({ ...prev, storageCriticalThreshold: value })); + } + }; + return ( <> @@ -93,6 +120,66 @@ export const SettingsButton = () => { error={balanceThreshold < 0 || balanceThreshold > 100000} /> + + + + Webhook Notifications + + Configure alerts for extractor expiry and storage capacity warnings. + {!webhookServerEnabled && " (Server webhook support not available - WEBHOOK_URL not configured)"} + + + + } + label="Enable webhook notifications" + /> + + {webhookConfig.enabled && webhookServerEnabled && ( + <> + + + + + + + )} + + + + + Colors + {Object.keys(colors).map((key) => { return (
diff --git a/src/app/context/Context.tsx b/src/app/context/Context.tsx index bdfb394..3ac1b5e 100644 --- a/src/app/context/Context.tsx +++ b/src/app/context/Context.tsx @@ -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>; showProductIcons: boolean; setShowProductIcons: (show: boolean) => void; + webhookConfig: WebhookConfig; + setWebhookConfig: Dispatch>; + 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 = { diff --git a/src/app/page.tsx b/src/app/page.tsx index cac18d7..aafd46b 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -8,16 +8,20 @@ import { AccessToken, CharacterUpdate, Env, PlanetWithInfo } from "../types"; import { MainGrid } from "./components/MainGrid"; import { refreshToken } from "@/esi-sso"; import { - CharacterContext, - ColorContext, - ColorSelectionType, - SessionContext, - defaultColors, + CharacterContext, + ColorContext, + ColorSelectionType, + SessionContext, + defaultColors, } from "./context/Context"; import { useSearchParams } from "next/navigation"; import { EvePraisalResult, fetchAllPrices } from "@/eve-praisal"; import { getPlanet, getPlanetUniverse, getPlanets } from "@/planets"; import { PlanetConfig } from "@/types"; +import { 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 ( @@ -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({ + enabled: true, // Enable by default for testing + expiryThreshold: 'P12H', + storageWarningThreshold: 85, + storageCriticalThreshold: 100 + }); + const [webhookServerEnabled, setWebhookServerEnabled] = useState(false); const [colors, setColors] = useState(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[] => { - localStorage.setItem("characters", JSON.stringify(characters)); + if (typeof window !== 'undefined') { + localStorage.setItem("characters", JSON.stringify(characters)); + } return characters; }; @@ -202,65 +237,122 @@ const Home = () => { }; useEffect(() => { - const storedCompactMode = localStorage.getItem("compactMode"); - if (!storedCompactMode) return; - storedCompactMode === "true" ? setCompactMode(true) : false; - }, []); - - 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)); + if (typeof window !== 'undefined') { + const storedCompactMode = localStorage.getItem("compactMode"); + if (storedCompactMode) { + setCompactMode(storedCompactMode === "true"); + } } }, []); 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]); useEffect(() => { - localStorage.setItem("compactMode", compactMode ? "true" : "false"); + if (typeof window !== 'undefined') { + localStorage.setItem("compactMode", compactMode ? "true" : "false"); + } }, [compactMode]); useEffect(() => { - localStorage.setItem("alertMode", alertMode ? "true" : "false"); + if (typeof window !== 'undefined') { + localStorage.setItem("alertMode", alertMode ? "true" : "false"); + } }, [alertMode]); useEffect(() => { - localStorage.setItem("colors", JSON.stringify(colors)); + if (typeof window !== 'undefined') { + localStorage.setItem("colors", JSON.stringify(colors)); + } }, [colors]); useEffect(() => { - const savedMode = localStorage.getItem('extractionTimeMode'); - if (savedMode) { - setExtractionTimeMode(savedMode === 'true'); + if (typeof window !== 'undefined') { + const savedMode = localStorage.getItem('extractionTimeMode'); + if (savedMode) { + setExtractionTimeMode(savedMode === 'true'); + } } }, []); useEffect(() => { - localStorage.setItem('extractionTimeMode', extractionTimeMode.toString()); + 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 ( { setBalanceThreshold, showProductIcons, setShowProductIcons, + webhookConfig, + setWebhookConfig, + webhookServerEnabled, }} > { 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', diff --git a/src/pages/api/webhook.ts b/src/pages/api/webhook.ts new file mode 100644 index 0000000..fea5c52 --- /dev/null +++ b/src/pages/api/webhook.ts @@ -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 }); + } +} diff --git a/src/types.ts b/src/types.ts index c2e6aad..89f7d14 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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 { diff --git a/src/types/webhook.ts b/src/types/webhook.ts new file mode 100644 index 0000000..8b79797 --- /dev/null +++ b/src/types/webhook.ts @@ -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; +} diff --git a/src/utils/webhookService.ts b/src/utils/webhookService.ts new file mode 100644 index 0000000..8baff91 --- /dev/null +++ b/src/utils/webhookService.ts @@ -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 => { + 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 => { + 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 => { + 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 => { + 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) + ]); +}; diff --git a/src/utils/webhookTracker.ts b/src/utils/webhookTracker.ts new file mode 100644 index 0000000..f678ed1 --- /dev/null +++ b/src/utils/webhookTracker.ts @@ -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(); + +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); + } + } +};