Compare commits

1 Commits
zulip ... main

Author SHA1 Message Date
Sparky
b49542a168 Add tmux run script 2025-10-10 11:59:30 +01:00
8 changed files with 442 additions and 330 deletions

View File

@@ -67,11 +67,8 @@ 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)
# Zulip Configuration (optional)
ZULIP_URL=https://zulip.site.quack-lab.dev/api/v1/messages
ZULIP_EMAIL=evepi-bot@zulip.site.quack-lab.dev
ZULIP_API_KEY=9LlHp6flAGWF0x5KZnyMJTM3qm4CiHkn
ZULIP_STREAM=EvePI
# Webhook Configuration (optional)
WEBHOOK_URL=Discord webhook URL for notifications
```
## Run locally

View File

@@ -170,66 +170,23 @@ export const SettingsButton = () => {
<Divider sx={{ mb: 2 }} />
<Typography variant="subtitle1">Webhook Notifications</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Configure Zulip alerts for extractor expiry and storage capacity warnings.
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}
checked={webhookConfig.enabled && webhookServerEnabled}
onChange={handleWebhookEnabledChange}
disabled={!webhookServerEnabled}
/>
}
label="Enable webhook notifications"
/>
{webhookConfig.enabled && (
{webhookConfig.enabled && webhookServerEnabled && (
<>
<Typography variant="subtitle2" sx={{ mt: 2, mb: 1 }}>Zulip Configuration</Typography>
<TextField
label="Zulip URL"
value={webhookConfig.zulipUrl || ''}
onChange={(e) => setWebhookConfig(prev => ({ ...prev, zulipUrl: e.target.value }))}
fullWidth
margin="normal"
placeholder="https://zulip.site.quack-lab.dev/api/v1/messages"
helperText="Zulip API endpoint URL"
/>
<TextField
label="Zulip Email"
value={webhookConfig.zulipEmail || ''}
onChange={(e) => setWebhookConfig(prev => ({ ...prev, zulipEmail: e.target.value }))}
fullWidth
margin="normal"
placeholder="evepi-bot@zulip.site.quack-lab.dev"
helperText="Bot email for authentication"
/>
<TextField
label="Zulip API Key"
type="password"
value={webhookConfig.zulipApiKey || ''}
onChange={(e) => setWebhookConfig(prev => ({ ...prev, zulipApiKey: e.target.value }))}
fullWidth
margin="normal"
placeholder="9LlHp6flAGWF0x5KZnyMJTM3qm4CiHkn"
helperText="API key for authentication"
/>
<TextField
label="Zulip Stream"
value={webhookConfig.zulipStream || ''}
onChange={(e) => setWebhookConfig(prev => ({ ...prev, zulipStream: e.target.value }))}
fullWidth
margin="normal"
placeholder="EvePI"
helperText="Stream name for messages"
/>
<Typography variant="subtitle2" sx={{ mt: 3, mb: 1 }}>Alert Thresholds</Typography>
<TextField
label="Extractor Expiry Warning"
value={webhookConfig.expiryThreshold}
@@ -262,33 +219,10 @@ export const SettingsButton = () => {
/>
<Button
onClick={async () => {
try {
const response = await fetch("/api/zulip-webhook", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
type: 'test',
message: 'Test webhook from EvePI',
characterName: 'Test Character',
planetName: 'Test Planet',
timestamp: new Date().toISOString(),
zulipUrl: webhookConfig.zulipUrl,
zulipEmail: webhookConfig.zulipEmail,
zulipApiKey: webhookConfig.zulipApiKey,
zulipStream: webhookConfig.zulipStream
})
});
if (response.ok) {
alert("✅ Test webhook sent successfully!");
} else {
const error = await response.text();
alert(`❌ Test webhook failed: ${error}`);
}
} catch (error) {
alert(`❌ Error testing webhook: ${error}`);
}
onClick={() => {
const { notificationService } = require("@/utils/notificationService");
const characters = JSON.parse(localStorage.getItem("characters") || "[]");
notificationService.checkAndNotify(characters, webhookConfig);
}}
variant="outlined"
sx={{ mt: 2 }}

View File

@@ -8,118 +8,21 @@ 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";
// Webhook service removed - using direct API calls
import { runWebhookChecks } from "@/utils/webhookService";
import { WebhookConfig } from "@/types/webhook";
import { cleanupOldWebhookStates } from "@/utils/webhookTracker";
import { planetCalculations } from "@/planets";
// Webhook check functions
const checkExtractorExpiry = async (character: any, planet: any, extractors: any[], config: WebhookConfig) => {
if (!config.enabled || !config.zulipUrl) return;
const now = new Date();
const expiryThreshold = new Date(now.getTime() + 12 * 60 * 60 * 1000); // 12 hours from now
for (const extractor of extractors) {
if (extractor.expiry_time) {
const expiryTime = new Date(extractor.expiry_time);
if (expiryTime <= expiryThreshold) {
const hoursRemaining = Math.round((expiryTime.getTime() - now.getTime()) / (1000 * 60 * 60));
const message = `⚠️ Extractor ${extractor.type_name} expires in ${hoursRemaining} hours`;
await fetch('/api/zulip-webhook', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type: 'extractor_expiring',
message,
characterName: character.character.name,
planetName: planet.infoUniverse.name,
timestamp: now.toISOString(),
zulipUrl: config.zulipUrl,
zulipEmail: config.zulipEmail,
zulipApiKey: config.zulipApiKey,
zulipStream: config.zulipStream
})
});
}
}
}
};
const checkStorageCapacity = async (character: any, planet: any, pins: any[], config: WebhookConfig) => {
if (!config.enabled || !config.zulipUrl) return;
// Import the storage constants
const { STORAGE_IDS, STORAGE_CAPACITIES, PI_PRODUCT_VOLUMES } = await import('@/const');
// Find storage facilities using the same logic as AccountCard
const storageFacilities = pins.filter((pin: any) =>
STORAGE_IDS().some((storage: any) => storage.type_id === pin.type_id)
);
for (const storage of storageFacilities) {
const storageType = STORAGE_IDS().find((s: any) => s.type_id === storage.type_id)?.name;
const storageCapacity = STORAGE_CAPACITIES[storage.type_id];
const totalVolume = storage.contents
.reduce((sum: number, item: any) => {
const volume = PI_PRODUCT_VOLUMES[item.type_id];
return sum + (item.amount * volume);
}, 0);
const fillPercentage = (totalVolume / storageCapacity) * 100;
if (fillPercentage >= config.storageCriticalThreshold) {
const message = `🚨 Storage ${storageType} is ${fillPercentage.toFixed(1)}% full!`;
await fetch('/api/zulip-webhook', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type: 'storage_full',
message,
characterName: character.character.name,
planetName: planet.infoUniverse.name,
timestamp: new Date().toISOString(),
zulipUrl: config.zulipUrl,
zulipEmail: config.zulipEmail,
zulipApiKey: config.zulipApiKey,
zulipStream: config.zulipStream
})
});
} else if (fillPercentage >= config.storageWarningThreshold) {
const message = `⚠️ Storage ${storageType} is ${fillPercentage.toFixed(1)}% full`;
await fetch('/api/zulip-webhook', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type: 'storage_almost_full',
message,
characterName: character.character.name,
planetName: planet.infoUniverse.name,
timestamp: new Date().toISOString(),
zulipUrl: config.zulipUrl,
zulipEmail: config.zulipEmail,
zulipApiKey: config.zulipApiKey,
zulipStream: config.zulipStream
})
});
}
}
};
// Add batch processing utility
const processInBatches = async <T, R>(
items: T[],
@@ -249,23 +152,29 @@ const Home = () => {
info: await getPlanet(c, p),
infoUniverse: await getPlanetUniverse(p),
})
);
);
// Run webhook checks for each planet if webhooks are enabled
if (webhookConfig.enabled) {
console.log(`🔍 Running webhook checks for ${c.character.name} (${planetsWithInfo.length} planets)`);
for (const planet of planetsWithInfo) {
try {
const calculations = planetCalculations(planet);
await checkExtractorExpiry(c, planet, calculations.extractors, webhookConfig);
await checkStorageCapacity(c, planet, planet.info.pins, webhookConfig);
} catch (error) {
console.warn('Webhook check failed for planet:', planet.infoUniverse.name, error);
}
// Run webhook checks for each planet if webhooks are enabled
if (webhookConfig.enabled) {
console.log(`🔍 Running webhook checks for ${c.character.name} (${planetsWithInfo.length} planets)`);
for (const planet of planetsWithInfo) {
try {
const calculations = planetCalculations(planet);
await runWebhookChecks(
c,
planet,
calculations.extractors,
webhookConfig
);
} catch (error) {
console.warn('Webhook check failed for planet:', planet.infoUniverse.name, error);
}
}
} else {
console.log('🔕 Webhooks disabled, skipping checks');
}
return {
return {
...c,
planets: planetsWithInfo,
};
@@ -496,7 +405,19 @@ const Home = () => {
for (const character of currentCharacters) {
if (character.needsLogin || !character.planets) continue;
// Webhook checks removed
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

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 with all the details
const webhookPayload = {
content: `${payload.message}\n**Character:** ${payload.characterName}\n**Planet:** ${payload.planetName}\n**Time:** ${new Date(payload.timestamp).toLocaleString()}`
};
console.log('📤 Sending payload:', JSON.stringify(webhookPayload, null, 2));
const response = await fetch(webhookUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(webhookPayload),
});
if (!response.ok) {
const errorText = await response.text();
console.error('❌ Webhook failed:', response.status, response.statusText, errorText);
throw new Error(`Webhook failed with status ${response.status}: ${errorText}`);
}
console.log('✅ Webhook sent successfully to external URL');
res.status(200).json({ success: true, message: 'Webhook sent to external URL' });
} catch (error) {
console.error('Webhook error:', error);
res.status(500).json({ error: 'Failed to send webhook', details: (error as Error).message });
}
}

View File

@@ -1,122 +0,0 @@
import { NextApiRequest, NextApiResponse } from 'next';
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 = req.body;
// Check if this is a test request
if (payload.type === 'test') {
const missing = [];
if (!payload.zulipUrl) missing.push('zulipUrl');
if (!payload.zulipEmail) missing.push('zulipEmail');
if (!payload.zulipApiKey) missing.push('zulipApiKey');
if (!payload.zulipStream) missing.push('zulipStream');
if (missing.length > 0) {
return res.status(400).json({ error: `Missing required Zulip configuration: ${missing.join(', ')}` });
}
const zulipUrl = payload.zulipUrl;
const zulipEmail = payload.zulipEmail;
const zulipApiKey = payload.zulipApiKey;
const zulipStream = payload.zulipStream;
const topic = 'Test-Webhook-Test';
const content = '🧪 **Test Webhook**\nThis is a test message from EvePI to verify Zulip integration is working correctly.\n\n**Time:** ' + new Date().toLocaleString();
const zulipPayload = new URLSearchParams();
zulipPayload.append('type', 'stream');
zulipPayload.append('to', zulipStream);
zulipPayload.append('topic', topic);
zulipPayload.append('content', content);
const auth = Buffer.from(`${zulipEmail}:${zulipApiKey}`).toString('base64');
const response = await fetch(zulipUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Authorization': `Basic ${auth}`
},
body: zulipPayload.toString()
});
if (!response.ok) {
const errorText = await response.text();
return res.status(400).json({
error: 'Zulip test webhook failed',
details: errorText,
status: response.status
});
}
const result = await response.text();
return res.status(200).json({
success: true,
message: 'Test webhook sent successfully to Zulip',
result
});
}
// Validate payload for normal webhook
if (!payload.type || !payload.message || !payload.characterName || !payload.planetName) {
return res.status(400).json({ error: 'Invalid payload' });
}
const zulipUrl = payload.zulipUrl;
const zulipEmail = payload.zulipEmail;
const zulipApiKey = payload.zulipApiKey;
const zulipStream = payload.zulipStream;
if (!zulipUrl || !zulipEmail || !zulipApiKey || !zulipStream) {
return res.status(400).json({ error: 'Missing required Zulip configuration' });
}
// Create topic in format: CharacterName-PlanetName-ActionType
const actionType = payload.type.replace(/_/g, ' ').replace(/\b\w/g, (l: string) => l.toUpperCase());
const topic = `${payload.characterName}-${payload.planetName}-${actionType}`;
// Format content with all the details
const content = `${payload.message}\n**Character:** ${payload.characterName}\n**Planet:** ${payload.planetName}\n**Time:** ${new Date(payload.timestamp).toLocaleString()}`;
// Create Zulip API payload
const zulipPayload = new URLSearchParams();
zulipPayload.append('type', 'stream');
zulipPayload.append('to', zulipStream);
zulipPayload.append('topic', topic);
zulipPayload.append('content', content);
console.log('🔔 Sending webhook to Zulip:', zulipUrl);
console.log('📤 Topic:', topic);
console.log('📤 Content:', content);
const response = await fetch(zulipUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Authorization': `Basic ${Buffer.from(`${zulipEmail}:${zulipApiKey}`).toString('base64')}`
},
body: zulipPayload.toString(),
});
if (!response.ok) {
const errorText = await response.text();
console.error('❌ Zulip webhook failed:', response.status, response.statusText, errorText);
throw new Error(`Zulip webhook failed with status ${response.status}: ${errorText}`);
}
console.log('✅ Webhook sent successfully to Zulip');
res.status(200).json({ success: true, message: 'Webhook sent to Zulip' });
} catch (error) {
console.error('Webhook error:', error);
res.status(500).json({ error: 'Failed to send webhook', details: (error as Error).message });
}
}

View File

@@ -3,14 +3,10 @@ export interface WebhookConfig {
expiryThreshold: string; // ISO 8601 duration format
storageWarningThreshold: number; // percentage
storageCriticalThreshold: number; // percentage
zulipUrl?: string;
zulipEmail?: string;
zulipApiKey?: string;
zulipStream?: string;
}
export interface WebhookPayload {
type: 'extractor_expiring' | 'extractor_expired' | 'storage_almost_full' | 'storage_full' | 'launchpad_almost_full' | 'launchpad_full' | 'test';
type: 'extractor_expiring' | 'extractor_expired' | 'storage_almost_full' | 'storage_full' | 'launchpad_almost_full' | 'launchpad_full';
message: string;
characterName: string;
planetName: string;
@@ -22,8 +18,4 @@ export interface WebhookPayload {
fillPercentage?: number;
};
timestamp: string;
zulipUrl?: string;
zulipEmail?: string;
zulipApiKey?: string;
zulipStream?: string;
}

View File

@@ -0,0 +1,102 @@
import { DateTime } from "luxon";
import { AccessToken, WebhookConfig } from "@/types";
import { planetCalculations } from "@/planets";
export class NotificationService {
private notificationState = new Map<string, boolean>();
private lastWebhookTime = 0;
private readonly WEBHOOK_COOLDOWN = 5000; // 5 seconds between webhooks
public checkAndNotify = async (
characters: AccessToken[],
webhookConfig: WebhookConfig
): Promise<void> => {
if (!webhookConfig.enabled) return;
const notifications: string[] = [];
const now = DateTime.now();
for (const character of characters) {
for (const planet of character.planets) {
const planetDetails = planetCalculations(planet);
// Check extractors for expiry warnings
for (const extractor of planetDetails.extractors) {
if (extractor.expiry_time) {
const expiryTime = DateTime.fromISO(extractor.expiry_time);
const identifier = `${character.character.name}-${planet.planet_id}-${extractor.pin_id}`;
// Check if already expired
if (expiryTime <= now) {
if (!this.notificationState.get(`${identifier}-expired`)) {
notifications.push(`🚨 EXTRACTOR EXPIRED - ${character.character.name} Planet ${planet.planet_id}`);
this.notificationState.set(`${identifier}-expired`, true);
}
} else {
// Check if expiring soon (within the warning threshold)
const warningThreshold = this.parseDuration(webhookConfig.expiryThreshold || "P12H");
const warningTime = now.plus(warningThreshold);
if (expiryTime <= warningTime && expiryTime > now) {
if (!this.notificationState.get(`${identifier}-warning`)) {
const hoursLeft = Math.round(expiryTime.diff(now, "hours").hours);
notifications.push(`⚠️ EXTRACTOR EXPIRING SOON - ${character.character.name} Planet ${planet.planet_id} (${hoursLeft}h left)`);
this.notificationState.set(`${identifier}-warning`, true);
}
}
}
}
}
// Check storage capacity
for (const storage of planetDetails.storageInfo) {
const identifier = `${character.character.name}-${planet.planet_id}-${storage.type_id}`;
if (storage.fillRate >= (webhookConfig.storageCriticalThreshold || 100)) {
if (!this.notificationState.get(`${identifier}-critical`)) {
notifications.push(`🚨 STORAGE CRITICAL - ${character.character.name} Planet ${planet.planet_id} (${storage.fillRate.toFixed(1)}%)`);
this.notificationState.set(`${identifier}-critical`, true);
}
} else if (storage.fillRate >= (webhookConfig.storageWarningThreshold || 85)) {
if (!this.notificationState.get(`${identifier}-warning`)) {
notifications.push(`⚠️ STORAGE WARNING - ${character.character.name} Planet ${planet.planet_id} (${storage.fillRate.toFixed(1)}%)`);
this.notificationState.set(`${identifier}-warning`, true);
}
}
}
}
}
if (notifications.length > 0) {
await this.sendWebhook(notifications.join("\n"));
}
};
private parseDuration(duration: string): any {
// Parse ISO 8601 duration (e.g., "P12H", "P1D", "PT2H30M")
try {
return DateTime.fromISO(`2000-01-01T00:00:00Z`).plus({ [duration.slice(-1) === 'H' ? 'hours' : duration.slice(-1) === 'D' ? 'days' : 'minutes' ]: parseInt(duration.slice(1, -1)) }).diff(DateTime.fromISO(`2000-01-01T00:00:00Z`));
} catch {
// Default to 12 hours if parsing fails
return { hours: 12 };
}
}
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 failed: ${response.status}`);
}
} catch (error) {
console.error("Webhook error:", error);
}
};
}
export const notificationService = new NotificationService();

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

@@ -0,0 +1,226 @@
import { DateTime, Duration } from 'luxon';
import { Pin, PlanetWithInfo, AccessToken } from '@/types';
import { StorageInfo } from '@/types/planet';
import { WebhookConfig, WebhookPayload } from '@/types/webhook';
import { STORAGE_IDS, LAUNCHPAD_IDS, PI_TYPES_MAP, STORAGE_CAPACITIES, PI_PRODUCT_VOLUMES } from '@/const';
import { shouldSendWebhook, markWebhookSent } from './webhookTracker';
const sendWebhook = async (payload: WebhookPayload): Promise<boolean> => {
try {
console.log('🔔 Sending webhook:', payload);
const response = await fetch('/api/webhook', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
});
if (response.ok) {
const result = await response.json();
console.log('✅ Webhook sent successfully:', result.message);
} else {
const error = await response.text();
console.log('❌ Webhook failed:', response.status, response.statusText, error);
}
return response.ok;
} catch (error) {
console.error('❌ Failed to send webhook:', error);
return false;
}
};
export const checkExtractorExpiry = async (
character: AccessToken,
planet: PlanetWithInfo,
extractors: Pin[],
config: WebhookConfig
): Promise<void> => {
if (!config.enabled) return;
if (extractors.length === 0) {
console.log(`📊 No extractors found on ${planet.infoUniverse.name}`);
return;
}
console.log(`⏰ Checking ${extractors.length} extractors on ${planet.infoUniverse.name}`);
const now = DateTime.now();
const expiryThreshold = Duration.fromISO(config.expiryThreshold);
for (const extractor of extractors) {
if (!extractor.expiry_time || !extractor.extractor_details?.product_type_id) continue;
const expiryTime = DateTime.fromISO(extractor.expiry_time);
const timeUntilExpiry = expiryTime.diff(now);
const hoursRemaining = timeUntilExpiry.as('hours');
const productType = PI_TYPES_MAP[extractor.extractor_details.product_type_id];
const extractorType = productType?.name || `Type ${extractor.extractor_details.product_type_id}`;
// Check if extractor has expired
if (expiryTime <= now) {
console.log(`⏰ Extractor expired: ${extractorType} on ${planet.infoUniverse.name}`);
const event = 'done';
if (shouldSendWebhook(character.character.characterId, planet.planet_id, extractor.expiry_time, event)) {
const payload: WebhookPayload = {
type: 'extractor_expired',
message: `Extractor producing ${extractorType} has expired on ${planet.infoUniverse.name}`,
characterName: character.character.name,
planetName: planet.infoUniverse.name,
details: {
extractorType,
hoursRemaining: 0
},
timestamp: now.toISO()
};
if (await sendWebhook(payload)) {
markWebhookSent(character.character.characterId, planet.planet_id, extractor.expiry_time, event);
}
}
}
// Check if extractor is about to expire
else if (timeUntilExpiry <= expiryThreshold) {
console.log(`⚠️ Extractor expiring soon: ${extractorType} on ${planet.infoUniverse.name} (${hoursRemaining.toFixed(1)}h remaining)`);
const event = 'nearly done';
if (shouldSendWebhook(character.character.characterId, planet.planet_id, extractor.expiry_time, event)) {
const payload: WebhookPayload = {
type: 'extractor_expiring',
message: `Extractor producing ${extractorType} will expire in ${hoursRemaining.toFixed(1)} hours on ${planet.infoUniverse.name}`,
characterName: character.character.name,
planetName: planet.infoUniverse.name,
details: {
extractorType,
hoursRemaining: Math.max(0, hoursRemaining)
},
timestamp: now.toISO()
};
if (await sendWebhook(payload)) {
markWebhookSent(character.character.characterId, planet.planet_id, extractor.expiry_time, event);
}
}
}
}
};
export const checkStorageCapacity = async (
character: AccessToken,
planet: PlanetWithInfo,
storageInfo: StorageInfo[],
config: WebhookConfig
): Promise<void> => {
if (!config.enabled) return;
if (storageInfo.length === 0) {
console.log(`📦 No storage facilities found on ${planet.infoUniverse.name}`);
return;
}
console.log(`📦 Checking ${storageInfo.length} storage facilities on ${planet.infoUniverse.name}`);
const now = DateTime.now();
for (const storage of storageInfo) {
const fillPercentage = (storage.used / storage.capacity) * 100;
const isLaunchpad = LAUNCHPAD_IDS.includes(storage.type_id);
const storageTypeName = PI_TYPES_MAP[storage.type_id]?.name || `Storage ${storage.type_id}`;
// Check for critical (100%) storage
if (fillPercentage >= config.storageCriticalThreshold) {
const webhookType = isLaunchpad ? 'launchpad_full' : 'storage_full';
console.log(`🚨 ${storageTypeName} is ${fillPercentage.toFixed(1)}% full on ${planet.infoUniverse.name}`);
if (shouldSendWebhook(character.character.characterId, planet.planet_id, webhookType, undefined, storage.type_id)) {
const payload: WebhookPayload = {
type: webhookType,
message: `${storageTypeName} is ${fillPercentage.toFixed(1)}% full on ${planet.infoUniverse.name}`,
characterName: character.character.name,
planetName: planet.infoUniverse.name,
details: {
storageUsed: storage.used,
storageCapacity: storage.capacity,
fillPercentage: fillPercentage
},
timestamp: now.toISO()
};
if (await sendWebhook(payload)) {
markWebhookSent(character.character.characterId, planet.planet_id, webhookType, undefined, storage.type_id);
}
}
}
// Check for warning threshold
else if (fillPercentage >= config.storageWarningThreshold) {
const webhookType = isLaunchpad ? 'launchpad_almost_full' : 'storage_almost_full';
console.log(`⚠️ ${storageTypeName} is ${fillPercentage.toFixed(1)}% full on ${planet.infoUniverse.name}`);
if (shouldSendWebhook(character.character.characterId, planet.planet_id, webhookType, undefined, storage.type_id)) {
const payload: WebhookPayload = {
type: webhookType,
message: `${storageTypeName} is ${fillPercentage.toFixed(1)}% full on ${planet.infoUniverse.name}`,
characterName: character.character.name,
planetName: planet.infoUniverse.name,
details: {
storageUsed: storage.used,
storageCapacity: storage.capacity,
fillPercentage: fillPercentage
},
timestamp: now.toISO()
};
if (await sendWebhook(payload)) {
markWebhookSent(character.character.characterId, planet.planet_id, webhookType, undefined, storage.type_id);
}
}
}
}
};
const calculateStorageInfo = (planet: PlanetWithInfo): StorageInfo[] => {
const storageFacilities = planet.info.pins.filter((pin: Pin) =>
STORAGE_IDS().some(storage => storage.type_id === pin.type_id)
);
return storageFacilities.map((storage: Pin): StorageInfo => {
const storageType = PI_TYPES_MAP[storage.type_id]?.name || 'Unknown';
const storageCapacity = STORAGE_CAPACITIES[storage.type_id] || 0;
const totalVolume = (storage.contents || [])
.reduce((sum: number, item) => {
const volume = PI_PRODUCT_VOLUMES[item.type_id] || 0;
return sum + (item.amount * volume);
}, 0);
const fillRate = storageCapacity > 0 ? (totalVolume / storageCapacity) * 100 : 0;
return {
type: storageType,
type_id: storage.type_id,
capacity: storageCapacity,
used: totalVolume,
fillRate: fillRate,
value: 0 // We don't need value for webhook checks
};
});
};
export const runWebhookChecks = async (
character: AccessToken,
planet: PlanetWithInfo,
extractors: Pin[],
config: WebhookConfig
): Promise<void> => {
if (!config.enabled) {
console.log('🔕 Webhooks disabled, skipping checks');
return;
}
console.log(`🔍 Running webhook checks for ${character.character.name} - ${planet.infoUniverse.name}`);
const storageInfo = calculateStorageInfo(planet);
await Promise.all([
checkExtractorExpiry(character, planet, extractors, config),
checkStorageCapacity(character, planet, storageInfo, config)
]);
};