Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
43cabd2722 | ||
| 598044c811 | |||
| bb946e9e6a | |||
| aacae79769 | |||
| 569ecf03e8 |
@@ -67,8 +67,11 @@ EVE_SSO_CLIENT_ID=Client ID
|
|||||||
EVE_SSO_SECRET=Secret Key
|
EVE_SSO_SECRET=Secret Key
|
||||||
EVE_SSO_CALLBACK_URL=Callback URL (This should be the domain you are hosting at or if run locally it should be http://localhost:3000)
|
EVE_SSO_CALLBACK_URL=Callback URL (This should be the domain you are hosting at or if run locally it should be http://localhost:3000)
|
||||||
|
|
||||||
# Webhook Configuration (optional)
|
# Zulip Configuration (optional)
|
||||||
WEBHOOK_URL=Discord webhook URL for notifications
|
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
|
||||||
```
|
```
|
||||||
|
|
||||||
## Run locally
|
## Run locally
|
||||||
|
|||||||
14
run.conf
Normal file
14
run.conf
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
# tmux-daemon.conf
|
||||||
|
# Configuration file for tmux-daemon.sh
|
||||||
|
|
||||||
|
# Session name
|
||||||
|
SESSION="eve-pi"
|
||||||
|
|
||||||
|
# Whether to attach to session after setup (0 or 1)
|
||||||
|
ATTACH_SESSION=0
|
||||||
|
|
||||||
|
# Commands to run (array format)
|
||||||
|
# Format: "workdir:::command" OR just "command"
|
||||||
|
CMDS=(
|
||||||
|
"bun dev"
|
||||||
|
)
|
||||||
141
run.sh
Normal file
141
run.sh
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# tmux-daemon.sh
|
||||||
|
# Idempotent tmux daemon manager with config file support
|
||||||
|
|
||||||
|
# Load config file if it exists
|
||||||
|
SCRIPT_DIR=$(dirname "$0")
|
||||||
|
SCRIPT_NAME=$(basename "$0" .sh)
|
||||||
|
CONFIG_FILE="${SCRIPT_DIR}/${SCRIPT_NAME}.conf"
|
||||||
|
if [ -f "$CONFIG_FILE" ]; then
|
||||||
|
echo "Loading config from $CONFIG_FILE"
|
||||||
|
source "$CONFIG_FILE"
|
||||||
|
else
|
||||||
|
echo "Config file $CONFIG_FILE not found, generating template..."
|
||||||
|
cat > "$CONFIG_FILE" << 'EOF'
|
||||||
|
# tmux-daemon.conf
|
||||||
|
# Configuration file for tmux-daemon.sh
|
||||||
|
|
||||||
|
# Session name
|
||||||
|
SESSION="example"
|
||||||
|
|
||||||
|
# Whether to attach to session after setup (0 or 1)
|
||||||
|
ATTACH_SESSION=0
|
||||||
|
|
||||||
|
# Commands to run (array format)
|
||||||
|
# Format: "workdir:::command" OR just "command"
|
||||||
|
CMDS=(
|
||||||
|
"ping google.com"
|
||||||
|
"ping google.com"
|
||||||
|
"ping google.com"
|
||||||
|
"ping google.com"
|
||||||
|
"ping google.com"
|
||||||
|
"ping google.com"
|
||||||
|
"ping google.com"
|
||||||
|
)
|
||||||
|
EOF
|
||||||
|
echo "Generated $CONFIG_FILE with default values. Please edit and run again."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Validate required variables
|
||||||
|
if [ -z "$SESSION" ]; then
|
||||||
|
echo "Error: SESSION must be set in $CONFIG_FILE" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ${#CMDS[@]} -eq 0 ]; then
|
||||||
|
echo "Error: CMDS array must be set in $CONFIG_FILE" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[main] SESSION: $SESSION"
|
||||||
|
echo "[main] Commands: ${CMDS[*]}"
|
||||||
|
|
||||||
|
# Create session if missing
|
||||||
|
if ! tmux has-session -t $SESSION 2>/dev/null; then
|
||||||
|
echo "[main] Creating tmux session: $SESSION"
|
||||||
|
tmux new-session -d -s $SESSION -n win0
|
||||||
|
else
|
||||||
|
echo "[main] Session $SESSION exists, reusing..."
|
||||||
|
fi
|
||||||
|
|
||||||
|
win=0
|
||||||
|
pane=0
|
||||||
|
|
||||||
|
for i in "${!CMDS[@]}"; do
|
||||||
|
entry="${CMDS[$i]}"
|
||||||
|
echo "[loop:$i] entry: $entry"
|
||||||
|
if [[ "$entry" == *":::"* ]]; then
|
||||||
|
workdir="${entry%%:::*}"
|
||||||
|
echo "[loop:$i] workdir set from entry: $workdir"
|
||||||
|
cmd="${entry#*:::}"
|
||||||
|
echo "[loop:$i] cmd set from entry: $cmd"
|
||||||
|
else
|
||||||
|
workdir="$PWD"
|
||||||
|
echo "[loop:$i] workdir set to PWD: $workdir"
|
||||||
|
cmd="$entry"
|
||||||
|
echo "[loop:$i] cmd set to entry: $cmd"
|
||||||
|
fi
|
||||||
|
echo "[loop:$i] Processing command: $cmd (workdir: $workdir)"
|
||||||
|
|
||||||
|
# Determine target window/pane
|
||||||
|
win=$((i / 4))
|
||||||
|
echo "[loop:$i] win updated: $win"
|
||||||
|
pane=$((i % 4))
|
||||||
|
echo "[loop:$i] pane updated: $pane"
|
||||||
|
|
||||||
|
# Create window if missing
|
||||||
|
if ! tmux list-windows -t $SESSION | grep -q "^$win:"; then
|
||||||
|
echo "[loop:$i] Creating window $win"
|
||||||
|
tmux new-window -t $SESSION:$win -n win$win
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Get current pane count in window
|
||||||
|
panes=($(tmux list-panes -t $SESSION:$win -F '#P'))
|
||||||
|
echo "[loop:$i] panes in window $win: ${panes[*]}"
|
||||||
|
|
||||||
|
# Create missing pane if needed
|
||||||
|
if [ $pane -ge ${#panes[@]} ]; then
|
||||||
|
echo "[loop:$i] Splitting pane $((pane-1)) to create pane $pane in window $win"
|
||||||
|
if [ $pane -eq 1 ]; then
|
||||||
|
tmux split-window -h -t $SESSION:$win
|
||||||
|
elif [ $pane -eq 2 ]; then
|
||||||
|
tmux split-window -v -t $SESSION:$win
|
||||||
|
elif [ $pane -eq 3 ]; then
|
||||||
|
tmux split-window -v -t $SESSION:$win.1
|
||||||
|
fi
|
||||||
|
tmux select-layout -t $SESSION:$win tiled >/dev/null
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if command is already running in the pane
|
||||||
|
running=$(tmux list-panes -t $SESSION:$win -F '#{pane_current_command}' | sed -n "$((pane+1))p")
|
||||||
|
echo "[loop:$i] running in pane $pane: $running"
|
||||||
|
if [[ "$running" == "$(basename "$cmd" | awk '{print $1}')" ]]; then
|
||||||
|
echo "[loop:$i] Command already running in pane $pane, skipping..."
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create temporary script to run command in auto-restart loop
|
||||||
|
tmpfile=$(mktemp)
|
||||||
|
echo "[loop:$i] tmpfile created: $tmpfile"
|
||||||
|
cat >"$tmpfile" <<EOF
|
||||||
|
#!/bin/bash
|
||||||
|
cd "$workdir" || exit 1
|
||||||
|
while true; do
|
||||||
|
echo '[tmux-daemon] Starting: $cmd (in $workdir)'
|
||||||
|
$cmd
|
||||||
|
echo '[tmux-daemon] Command exited with code \$?'
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
EOF
|
||||||
|
chmod +x "$tmpfile"
|
||||||
|
echo "[loop:$i] Sending command to pane $pane in window $win"
|
||||||
|
tmux send-keys -t $SESSION:$win.$pane "$tmpfile" C-m
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "[main] Done."
|
||||||
|
|
||||||
|
if [ $ATTACH_SESSION -eq 1 ]; then
|
||||||
|
echo "[main] Attaching to tmux session: $SESSION"
|
||||||
|
tmux attach -t $SESSION
|
||||||
|
fi
|
||||||
@@ -170,23 +170,66 @@ export const SettingsButton = () => {
|
|||||||
<Divider sx={{ mb: 2 }} />
|
<Divider sx={{ mb: 2 }} />
|
||||||
<Typography variant="subtitle1">Webhook Notifications</Typography>
|
<Typography variant="subtitle1">Webhook Notifications</Typography>
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||||
Configure alerts for extractor expiry and storage capacity warnings.
|
Configure Zulip alerts for extractor expiry and storage capacity warnings.
|
||||||
{!webhookServerEnabled && " (Server webhook support not available - WEBHOOK_URL not configured)"}
|
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
<FormControlLabel
|
<FormControlLabel
|
||||||
control={
|
control={
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={webhookConfig.enabled && webhookServerEnabled}
|
checked={webhookConfig.enabled}
|
||||||
onChange={handleWebhookEnabledChange}
|
onChange={handleWebhookEnabledChange}
|
||||||
disabled={!webhookServerEnabled}
|
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
label="Enable webhook notifications"
|
label="Enable webhook notifications"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{webhookConfig.enabled && webhookServerEnabled && (
|
{webhookConfig.enabled && (
|
||||||
<>
|
<>
|
||||||
|
<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
|
<TextField
|
||||||
label="Extractor Expiry Warning"
|
label="Extractor Expiry Warning"
|
||||||
value={webhookConfig.expiryThreshold}
|
value={webhookConfig.expiryThreshold}
|
||||||
@@ -219,10 +262,33 @@ export const SettingsButton = () => {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
onClick={async () => {
|
||||||
const { notificationService } = require("@/utils/notificationService");
|
try {
|
||||||
const characters = JSON.parse(localStorage.getItem("characters") || "[]");
|
const response = await fetch("/api/zulip-webhook", {
|
||||||
notificationService.checkAndNotify(characters, webhookConfig);
|
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}`);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
sx={{ mt: 2 }}
|
sx={{ mt: 2 }}
|
||||||
|
|||||||
155
src/app/page.tsx
155
src/app/page.tsx
@@ -8,21 +8,118 @@ import { AccessToken, CharacterUpdate, Env, PlanetWithInfo } from "../types";
|
|||||||
import { MainGrid } from "./components/MainGrid";
|
import { MainGrid } from "./components/MainGrid";
|
||||||
import { refreshToken } from "@/esi-sso";
|
import { refreshToken } from "@/esi-sso";
|
||||||
import {
|
import {
|
||||||
CharacterContext,
|
CharacterContext,
|
||||||
ColorContext,
|
ColorContext,
|
||||||
ColorSelectionType,
|
ColorSelectionType,
|
||||||
SessionContext,
|
SessionContext,
|
||||||
defaultColors,
|
defaultColors,
|
||||||
} from "./context/Context";
|
} from "./context/Context";
|
||||||
import { useSearchParams } from "next/navigation";
|
import { useSearchParams } from "next/navigation";
|
||||||
import { EvePraisalResult, fetchAllPrices } from "@/eve-praisal";
|
import { EvePraisalResult, fetchAllPrices } from "@/eve-praisal";
|
||||||
import { getPlanet, getPlanetUniverse, getPlanets } from "@/planets";
|
import { getPlanet, getPlanetUniverse, getPlanets } from "@/planets";
|
||||||
import { PlanetConfig } from "@/types";
|
import { PlanetConfig } from "@/types";
|
||||||
import { runWebhookChecks } from "@/utils/webhookService";
|
// Webhook service removed - using direct API calls
|
||||||
import { WebhookConfig } from "@/types/webhook";
|
import { WebhookConfig } from "@/types/webhook";
|
||||||
import { cleanupOldWebhookStates } from "@/utils/webhookTracker";
|
import { cleanupOldWebhookStates } from "@/utils/webhookTracker";
|
||||||
import { planetCalculations } from "@/planets";
|
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
|
// Add batch processing utility
|
||||||
const processInBatches = async <T, R>(
|
const processInBatches = async <T, R>(
|
||||||
items: T[],
|
items: T[],
|
||||||
@@ -152,29 +249,23 @@ const Home = () => {
|
|||||||
info: await getPlanet(c, p),
|
info: await getPlanet(c, p),
|
||||||
infoUniverse: await getPlanetUniverse(p),
|
infoUniverse: await getPlanetUniverse(p),
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
// Run webhook checks for each planet if webhooks are enabled
|
// Run webhook checks for each planet if webhooks are enabled
|
||||||
if (webhookConfig.enabled) {
|
if (webhookConfig.enabled) {
|
||||||
console.log(`🔍 Running webhook checks for ${c.character.name} (${planetsWithInfo.length} planets)`);
|
console.log(`🔍 Running webhook checks for ${c.character.name} (${planetsWithInfo.length} planets)`);
|
||||||
for (const planet of planetsWithInfo) {
|
for (const planet of planetsWithInfo) {
|
||||||
try {
|
try {
|
||||||
const calculations = planetCalculations(planet);
|
const calculations = planetCalculations(planet);
|
||||||
await runWebhookChecks(
|
await checkExtractorExpiry(c, planet, calculations.extractors, webhookConfig);
|
||||||
c,
|
await checkStorageCapacity(c, planet, planet.info.pins, webhookConfig);
|
||||||
planet,
|
} catch (error) {
|
||||||
calculations.extractors,
|
console.warn('Webhook check failed for planet:', planet.infoUniverse.name, error);
|
||||||
webhookConfig
|
}
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('Webhook check failed for planet:', planet.infoUniverse.name, error);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
console.log('🔕 Webhooks disabled, skipping checks');
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...c,
|
...c,
|
||||||
planets: planetsWithInfo,
|
planets: planetsWithInfo,
|
||||||
};
|
};
|
||||||
@@ -405,19 +496,7 @@ const Home = () => {
|
|||||||
for (const character of currentCharacters) {
|
for (const character of currentCharacters) {
|
||||||
if (character.needsLogin || !character.planets) continue;
|
if (character.needsLogin || !character.planets) continue;
|
||||||
|
|
||||||
for (const planet of character.planets) {
|
// Webhook checks removed
|
||||||
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
|
// Cleanup old webhook states
|
||||||
|
|||||||
@@ -1,62 +0,0 @@
|
|||||||
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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
122
src/pages/api/zulip-webhook.ts
Normal file
122
src/pages/api/zulip-webhook.ts
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,10 +3,14 @@ export interface WebhookConfig {
|
|||||||
expiryThreshold: string; // ISO 8601 duration format
|
expiryThreshold: string; // ISO 8601 duration format
|
||||||
storageWarningThreshold: number; // percentage
|
storageWarningThreshold: number; // percentage
|
||||||
storageCriticalThreshold: number; // percentage
|
storageCriticalThreshold: number; // percentage
|
||||||
|
zulipUrl?: string;
|
||||||
|
zulipEmail?: string;
|
||||||
|
zulipApiKey?: string;
|
||||||
|
zulipStream?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WebhookPayload {
|
export interface WebhookPayload {
|
||||||
type: 'extractor_expiring' | 'extractor_expired' | 'storage_almost_full' | 'storage_full' | 'launchpad_almost_full' | 'launchpad_full';
|
type: 'extractor_expiring' | 'extractor_expired' | 'storage_almost_full' | 'storage_full' | 'launchpad_almost_full' | 'launchpad_full' | 'test';
|
||||||
message: string;
|
message: string;
|
||||||
characterName: string;
|
characterName: string;
|
||||||
planetName: string;
|
planetName: string;
|
||||||
@@ -18,4 +22,8 @@ export interface WebhookPayload {
|
|||||||
fillPercentage?: number;
|
fillPercentage?: number;
|
||||||
};
|
};
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
|
zulipUrl?: string;
|
||||||
|
zulipEmail?: string;
|
||||||
|
zulipApiKey?: string;
|
||||||
|
zulipStream?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,102 +0,0 @@
|
|||||||
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();
|
|
||||||
@@ -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)
|
|
||||||
]);
|
|
||||||
};
|
|
||||||
Reference in New Issue
Block a user