Compare commits
10 Commits
82877b8870
...
zulip
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
43cabd2722 | ||
| 598044c811 | |||
| bb946e9e6a | |||
| aacae79769 | |||
| 569ecf03e8 | |||
| 0a5c256569 | |||
| f081b3aba2 | |||
| b5dc367713 | |||
| b56a950f04 | |||
| 46289d4667 |
@@ -4,4 +4,5 @@ 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)
|
||||
NEXT_PUBLIC_PRAISAL_URL=https://praisal.avanto.tk/appraisal/structured.json?persist=no
|
||||
SENTRY_AUTH_TOKEN=Sentry token for error reporting.
|
||||
LOG_LEVEL=warn
|
||||
LOG_LEVEL=warn
|
||||
WEBHOOK_URL=Webhook URL
|
||||
59
README.md
59
README.md
@@ -20,6 +20,7 @@ Features:
|
||||
- Backup to download characters to a file
|
||||
- Rstore from a file. Must be from the same instance!
|
||||
- View the 3D render of the planet with your PI setup by clicking the planet
|
||||
- Webhook notifications for extractor expiry, storage capacity, and launch pad capacity
|
||||
|
||||
## Support with hosting
|
||||
|
||||
@@ -65,6 +66,12 @@ 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)
|
||||
|
||||
# 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
|
||||
```
|
||||
|
||||
## Run locally
|
||||
@@ -72,6 +79,58 @@ EVE_SSO_CALLBACK_URL=Callback URL (This should be the domain you are hosting at
|
||||
1. Create .env file in the directory root and populate with env variables you get from the EVE app you created. Example env file: .env.example
|
||||
2. run `npm run dev`
|
||||
|
||||
## Webhook Notifications
|
||||
|
||||
The application supports webhook notifications for various PI events:
|
||||
|
||||
- **Extractor expiry warnings**: Notify when extractors are about to run out (configurable hours before expiry)
|
||||
- **Extractor expired**: Notify when extractors have run out
|
||||
- **Storage capacity warnings**: Notify when storage is about to be full (configurable percentage)
|
||||
- **Storage full**: Notify when storage is at 100% capacity
|
||||
- **Launch pad capacity warnings**: Notify when launch pads are about to be full (configurable percentage)
|
||||
- **Launch pad full**: Notify when launch pads are at 100% capacity
|
||||
|
||||
### Webhook Configuration
|
||||
|
||||
To enable webhook notifications:
|
||||
|
||||
1. Set the `WEBHOOK_URL` environment variable to your Discord webhook URL or other webhook endpoint
|
||||
2. Open the Settings dialog in the application and enable webhook notifications
|
||||
3. Configure thresholds in the Settings dialog:
|
||||
- **Extractor Expiry Warning**: How early to warn about extractor expiry (ISO 8601 duration format, default: P12H = 12 hours)
|
||||
- **Storage Warning Threshold**: Storage fill percentage for warnings (default: 85%)
|
||||
- **Storage Critical Threshold**: Storage fill percentage for critical alerts (default: 100%)
|
||||
|
||||
### Webhook Behavior
|
||||
|
||||
- **Automatic checks**: Runs every minute to check extractor expiry and storage levels
|
||||
- **Data fetch checks**: Also checks when planet data is refreshed
|
||||
- **Duplicate prevention**: Won't send the same alert repeatedly within cooldown periods
|
||||
- **State reset**: Extractor alerts reset when extractors are restarted with fresh cycles
|
||||
|
||||
### Webhook Payload Format
|
||||
|
||||
The webhook sends JSON payloads with this structure:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "extractor_expiring|extractor_expired|storage_almost_full|storage_full|launchpad_almost_full|launchpad_full",
|
||||
"message": "Human readable message",
|
||||
"characterName": "Character Name",
|
||||
"planetName": "Planet Name",
|
||||
"details": {
|
||||
"extractorType": "Product Type",
|
||||
"hoursRemaining": 1.5,
|
||||
"storageUsed": 8500,
|
||||
"storageCapacity": 10000,
|
||||
"fillPercentage": 85.0
|
||||
},
|
||||
"timestamp": "2024-01-01T12:00:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
The webhook URL is kept secure in environment variables and not stored in the browser.
|
||||
|
||||
## Run the container
|
||||
|
||||
1. Populate the environment variables in .env file
|
||||
|
||||
@@ -8,6 +8,7 @@ services:
|
||||
- EVE_SSO_SECRET=${EVE_SSO_SECRET}
|
||||
- NEXT_PUBLIC_PRAISAL_URL=${NEXT_PUBLIC_PRAISAL_URL}
|
||||
- SENTRY_AUTH_TOKEN=${SENTRY_AUTH_TOKEN}
|
||||
- WEBHOOK_URL=${WEBHOOK_URL}
|
||||
- LOG_LEVEL=warn
|
||||
ports:
|
||||
- 3000:3000
|
||||
|
||||
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
|
||||
@@ -15,13 +15,14 @@ import {
|
||||
Box,
|
||||
FormControlLabel,
|
||||
Checkbox,
|
||||
Divider,
|
||||
} from "@mui/material";
|
||||
import { ColorChangeHandler, ColorResult, CompactPicker } from "react-color";
|
||||
import { ColorResult, CompactPicker } from "react-color";
|
||||
import React, { useState, useContext } from "react";
|
||||
|
||||
export const SettingsButton = () => {
|
||||
const { colors, setColors } = useContext(ColorContext);
|
||||
const { balanceThreshold, setBalanceThreshold, showProductIcons, setShowProductIcons } = useContext(SessionContext);
|
||||
const { balanceThreshold, setBalanceThreshold, showProductIcons, setShowProductIcons, webhookConfig, setWebhookConfig, webhookServerEnabled } = useContext(SessionContext);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const handleClickOpen = () => {
|
||||
@@ -51,6 +52,57 @@ export const SettingsButton = () => {
|
||||
setShowProductIcons(event.target.checked);
|
||||
};
|
||||
|
||||
const handleWebhookEnabledChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (!webhookServerEnabled && event.target.checked) {
|
||||
// Don't allow enabling if server doesn't support it
|
||||
return;
|
||||
}
|
||||
setWebhookConfig(prev => ({ ...prev, enabled: event.target.checked }));
|
||||
};
|
||||
|
||||
const handleExpiryThresholdChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setWebhookConfig(prev => ({ ...prev, expiryThreshold: event.target.value }));
|
||||
};
|
||||
|
||||
const handleStorageWarningChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = parseInt(event.target.value);
|
||||
if (!isNaN(value) && value >= 0 && value <= 100) {
|
||||
setWebhookConfig(prev => ({ ...prev, storageWarningThreshold: value }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleStorageCriticalChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = parseInt(event.target.value);
|
||||
if (!isNaN(value) && value >= 0 && value <= 100) {
|
||||
setWebhookConfig(prev => ({ ...prev, storageCriticalThreshold: value }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleRefreshStats = () => {
|
||||
// Trigger a manual refresh of character data
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
const handleRefreshESI = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/refresh", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ force: true })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
console.log("✅ ESI data refreshed successfully");
|
||||
// Optionally reload the page to show fresh data
|
||||
window.location.reload();
|
||||
} else {
|
||||
console.error("❌ Failed to refresh ESI data");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("❌ Error refreshing ESI data:", error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Tooltip title="Toggle settings dialog">
|
||||
<>
|
||||
@@ -93,6 +145,165 @@ export const SettingsButton = () => {
|
||||
error={balanceThreshold < 0 || balanceThreshold > 100000}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Typography variant="subtitle1">Data Refresh</Typography>
|
||||
<Button
|
||||
onClick={handleRefreshESI}
|
||||
variant="contained"
|
||||
fullWidth
|
||||
sx={{ mt: 1 }}
|
||||
>
|
||||
Refresh ESI Data
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleRefreshStats}
|
||||
variant="outlined"
|
||||
fullWidth
|
||||
sx={{ mt: 1 }}
|
||||
>
|
||||
Refresh All Data
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mt: 3 }}>
|
||||
<Divider sx={{ mb: 2 }} />
|
||||
<Typography variant="subtitle1">Webhook Notifications</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
Configure Zulip alerts for extractor expiry and storage capacity warnings.
|
||||
</Typography>
|
||||
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={webhookConfig.enabled}
|
||||
onChange={handleWebhookEnabledChange}
|
||||
/>
|
||||
}
|
||||
label="Enable webhook notifications"
|
||||
/>
|
||||
|
||||
{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
|
||||
label="Extractor Expiry Warning"
|
||||
value={webhookConfig.expiryThreshold}
|
||||
onChange={handleExpiryThresholdChange}
|
||||
fullWidth
|
||||
margin="normal"
|
||||
helperText="ISO 8601 duration format (e.g., P12H for 12 hours, P1D for 1 day)"
|
||||
/>
|
||||
|
||||
<TextField
|
||||
type="number"
|
||||
label="Storage Warning Threshold (%)"
|
||||
value={webhookConfig.storageWarningThreshold}
|
||||
onChange={handleStorageWarningChange}
|
||||
fullWidth
|
||||
margin="normal"
|
||||
inputProps={{ min: 0, max: 100 }}
|
||||
helperText="Alert when storage reaches this percentage (0-100)"
|
||||
/>
|
||||
|
||||
<TextField
|
||||
type="number"
|
||||
label="Storage Critical Threshold (%)"
|
||||
value={webhookConfig.storageCriticalThreshold}
|
||||
onChange={handleStorageCriticalChange}
|
||||
fullWidth
|
||||
margin="normal"
|
||||
inputProps={{ min: 0, max: 100 }}
|
||||
helperText="Alert when storage is completely full (usually 100)"
|
||||
/>
|
||||
|
||||
<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}`);
|
||||
}
|
||||
}}
|
||||
variant="outlined"
|
||||
sx={{ mt: 2 }}
|
||||
fullWidth
|
||||
>
|
||||
Test Webhook
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mt: 3 }}>
|
||||
<Divider sx={{ mb: 2 }} />
|
||||
<Typography variant="subtitle1">Colors</Typography>
|
||||
</Box>
|
||||
{Object.keys(colors).map((key) => {
|
||||
return (
|
||||
<div key={`color-row-${key}`}>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { EvePraisalResult } from "@/eve-praisal";
|
||||
import { AccessToken, CharacterUpdate, PlanetConfig } from "@/types";
|
||||
import { WebhookConfig } from "@/types/webhook";
|
||||
import { Dispatch, SetStateAction, createContext } from "react";
|
||||
|
||||
export const CharacterContext = createContext<{
|
||||
@@ -41,6 +42,9 @@ export const SessionContext = createContext<{
|
||||
setBalanceThreshold: Dispatch<SetStateAction<number>>;
|
||||
showProductIcons: boolean;
|
||||
setShowProductIcons: (show: boolean) => void;
|
||||
webhookConfig: WebhookConfig;
|
||||
setWebhookConfig: Dispatch<SetStateAction<WebhookConfig>>;
|
||||
webhookServerEnabled: boolean;
|
||||
}>({
|
||||
sessionReady: false,
|
||||
refreshSession: () => {},
|
||||
@@ -70,6 +74,14 @@ export const SessionContext = createContext<{
|
||||
setBalanceThreshold: () => {},
|
||||
showProductIcons: false,
|
||||
setShowProductIcons: () => {},
|
||||
webhookConfig: {
|
||||
enabled: false,
|
||||
expiryThreshold: 'P12H',
|
||||
storageWarningThreshold: 85,
|
||||
storageCriticalThreshold: 100
|
||||
},
|
||||
setWebhookConfig: () => {},
|
||||
webhookServerEnabled: false,
|
||||
});
|
||||
|
||||
export type ColorSelectionType = {
|
||||
|
||||
290
src/app/page.tsx
290
src/app/page.tsx
@@ -18,6 +18,107 @@ 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 { 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>(
|
||||
@@ -47,6 +148,21 @@ const Home = () => {
|
||||
const [balanceThreshold, setBalanceThreshold] = useState(1000);
|
||||
const [showProductIcons, setShowProductIcons] = useState(false);
|
||||
const [extractionTimeMode, setExtractionTimeMode] = useState(false);
|
||||
const [webhookConfig, setWebhookConfig] = useState<WebhookConfig>(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const stored = localStorage.getItem("webhookConfig");
|
||||
if (stored) {
|
||||
return JSON.parse(stored);
|
||||
}
|
||||
}
|
||||
return {
|
||||
enabled: false,
|
||||
expiryThreshold: 'P12H',
|
||||
storageWarningThreshold: 85,
|
||||
storageCriticalThreshold: 100
|
||||
};
|
||||
});
|
||||
const [webhookServerEnabled, setWebhookServerEnabled] = useState(false);
|
||||
|
||||
const [colors, setColors] = useState<ColorSelectionType>(defaultColors);
|
||||
const [alertMode, setAlertMode] = useState(false);
|
||||
@@ -109,6 +225,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);
|
||||
@@ -132,15 +249,32 @@ const Home = () => {
|
||||
info: await getPlanet(c, p),
|
||||
infoUniverse: await getPlanetUniverse(p),
|
||||
})
|
||||
);
|
||||
return {
|
||||
);
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...c,
|
||||
planets: planetsWithInfo,
|
||||
};
|
||||
});
|
||||
|
||||
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 +336,123 @@ 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 +481,37 @@ 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;
|
||||
|
||||
// Webhook checks removed
|
||||
}
|
||||
|
||||
// Cleanup old webhook states
|
||||
cleanupOldWebhookStates();
|
||||
};
|
||||
|
||||
// Run immediately
|
||||
runRegularWebhookChecks();
|
||||
|
||||
// Set up interval for every minute
|
||||
const webhookInterval = setInterval(runRegularWebhookChecks, 60000);
|
||||
|
||||
return () => clearInterval(webhookInterval);
|
||||
}, [webhookConfig.enabled]);
|
||||
|
||||
return (
|
||||
<SessionContext.Provider
|
||||
value={{
|
||||
@@ -312,6 +535,9 @@ const Home = () => {
|
||||
setBalanceThreshold,
|
||||
showProductIcons,
|
||||
setShowProductIcons,
|
||||
webhookConfig,
|
||||
setWebhookConfig,
|
||||
webhookServerEnabled,
|
||||
}}
|
||||
>
|
||||
<CharacterContext.Provider
|
||||
|
||||
@@ -10,6 +10,7 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
try {
|
||||
const EVE_SSO_CALLBACK_URL = process.env.EVE_SSO_CALLBACK_URL;
|
||||
const EVE_SSO_CLIENT_ID = process.env.EVE_SSO_CLIENT_ID;
|
||||
const WEBHOOK_ENABLED = !!process.env.WEBHOOK_URL;
|
||||
|
||||
if (!EVE_SSO_CALLBACK_URL || !EVE_SSO_CLIENT_ID) {
|
||||
logger.error({
|
||||
@@ -27,11 +28,16 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
event: 'env_request_success',
|
||||
vars: {
|
||||
hasCallbackUrl: true,
|
||||
hasClientId: true
|
||||
hasClientId: true,
|
||||
webhookEnabled: WEBHOOK_ENABLED
|
||||
}
|
||||
});
|
||||
|
||||
return res.json({ EVE_SSO_CLIENT_ID, EVE_SSO_CALLBACK_URL });
|
||||
return res.json({
|
||||
EVE_SSO_CLIENT_ID,
|
||||
EVE_SSO_CALLBACK_URL,
|
||||
WEBHOOK_ENABLED
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error({
|
||||
event: 'env_request_failed',
|
||||
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
29
src/types/webhook.ts
Normal file
29
src/types/webhook.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
export interface WebhookConfig {
|
||||
enabled: boolean;
|
||||
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';
|
||||
message: string;
|
||||
characterName: string;
|
||||
planetName: string;
|
||||
details?: {
|
||||
extractorType?: string;
|
||||
hoursRemaining?: number;
|
||||
storageUsed?: number;
|
||||
storageCapacity?: number;
|
||||
fillPercentage?: number;
|
||||
};
|
||||
timestamp: string;
|
||||
zulipUrl?: string;
|
||||
zulipEmail?: string;
|
||||
zulipApiKey?: string;
|
||||
zulipStream?: string;
|
||||
}
|
||||
122
src/utils/webhookTracker.ts
Normal file
122
src/utils/webhookTracker.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
|
||||
interface WebhookState {
|
||||
sent: boolean;
|
||||
lastSent: number;
|
||||
}
|
||||
|
||||
// Create hash for unique events: character-planet-extractorEndTimestamp-event
|
||||
export const createWebhookHash = (
|
||||
characterId: number,
|
||||
planetId: number,
|
||||
extractorEndTimestamp: string,
|
||||
event: string
|
||||
): string => {
|
||||
return `${characterId}-${planetId}-${extractorEndTimestamp}-${event}`;
|
||||
};
|
||||
|
||||
// Get webhook state from localStorage
|
||||
const getWebhookState = (hash: string): WebhookState => {
|
||||
if (typeof window === 'undefined') return { sent: false, lastSent: 0 };
|
||||
|
||||
try {
|
||||
const stored = localStorage.getItem(`webhook_${hash}`);
|
||||
if (stored) {
|
||||
return JSON.parse(stored);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to parse webhook state from localStorage:', error);
|
||||
}
|
||||
|
||||
return { sent: false, lastSent: 0 };
|
||||
};
|
||||
|
||||
// Set webhook state in localStorage
|
||||
const setWebhookState = (hash: string, state: WebhookState): void => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
try {
|
||||
localStorage.setItem(`webhook_${hash}`, JSON.stringify(state));
|
||||
} catch (error) {
|
||||
console.warn('Failed to save webhook state to localStorage:', error);
|
||||
}
|
||||
};
|
||||
|
||||
export const shouldSendWebhook = (
|
||||
characterId: number,
|
||||
planetId: number,
|
||||
extractorEndTimestamp: string,
|
||||
event: string
|
||||
): boolean => {
|
||||
const hash = createWebhookHash(characterId, planetId, extractorEndTimestamp, event);
|
||||
const state = getWebhookState(hash);
|
||||
|
||||
return !state.sent;
|
||||
};
|
||||
|
||||
// Storage webhook tracking with 1-hour cooldown
|
||||
export const shouldSendStorageWebhook = (
|
||||
characterId: number,
|
||||
planetId: number,
|
||||
storageTypeId: number,
|
||||
webhookType: string
|
||||
): boolean => {
|
||||
const hash = `storage-${characterId}-${planetId}-${storageTypeId}-${webhookType}`;
|
||||
const state = getWebhookState(hash);
|
||||
|
||||
if (!state.sent) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if 1 hour has passed since last notification
|
||||
const oneHourAgo = Date.now() - (60 * 60 * 1000);
|
||||
return state.lastSent < oneHourAgo;
|
||||
};
|
||||
|
||||
export const markStorageWebhookSent = (
|
||||
characterId: number,
|
||||
planetId: number,
|
||||
storageTypeId: number,
|
||||
webhookType: string
|
||||
): void => {
|
||||
const hash = `storage-${characterId}-${planetId}-${storageTypeId}-${webhookType}`;
|
||||
setWebhookState(hash, { sent: true, lastSent: Date.now() });
|
||||
};
|
||||
|
||||
export const markWebhookSent = (
|
||||
characterId: number,
|
||||
planetId: number,
|
||||
extractorEndTimestamp: string,
|
||||
event: string
|
||||
): void => {
|
||||
const hash = createWebhookHash(characterId, planetId, extractorEndTimestamp, event);
|
||||
setWebhookState(hash, { sent: true, lastSent: Date.now() });
|
||||
};
|
||||
|
||||
// Cleanup old webhook states (older than 7 days)
|
||||
export const cleanupOldWebhookStates = (): void => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const cutoff = Date.now() - (7 * 24 * 60 * 60 * 1000); // 7 days ago
|
||||
|
||||
try {
|
||||
const keysToRemove: string[] = [];
|
||||
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i);
|
||||
if (key && key.startsWith('webhook_')) {
|
||||
const state = JSON.parse(localStorage.getItem(key) || '{}');
|
||||
if (state.lastSent && state.lastSent < cutoff) {
|
||||
keysToRemove.push(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
keysToRemove.forEach(key => localStorage.removeItem(key));
|
||||
|
||||
if (keysToRemove.length > 0) {
|
||||
console.log(`🧹 Cleaned up ${keysToRemove.length} old webhook states`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to cleanup old webhook states:', error);
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user