Compare commits

..

4 commits

Author SHA1 Message Date
MoSiren 93ac9d14bf Update data/wfos.json
Some checks failed
ptero-push / build (push) Has been cancelled
2025-07-30 02:36:08 -06:00
MoSiren 664e00b7f4 Update data/events.json
Some checks failed
ptero-push / build (push) Has been cancelled
updated some pri codes
2025-04-18 19:35:53 -06:00
MoSiren cce9b12645 Update data/events.json
Some checks are pending
ptero-push / build (push) Waiting to run
Flash Flood Warning Is Level 5
2025-04-18 19:30:46 -06:00
MoSiren c82ab68c1e Nexlabs Outlooks
Some checks failed
ptero-push / build (push) Has been cancelled
2025-03-13 23:51:03 -06:00
5 changed files with 400 additions and 303 deletions

View file

@ -365,7 +365,7 @@
},
"FFW": {
"text": "Flash Flood Warning",
"priority": 4
"priority": 5
},
"FLN": {
"priority": 1,
@ -1164,7 +1164,7 @@
"text": "Tropical Cyclone Watch/Warning Break Points"
},
"TIB": {
"priority": 4,
"priority": 3,
"text": "Tsunami Bulletin"
},
"TID": {
@ -1308,7 +1308,7 @@
"text": "Routine Space Environment Product Issued Weekly"
},
"WOU": {
"priority": 4,
"priority": 5,
"text": "Tornado/Severe Thunderstorm Watch"
},
"WS1": {
@ -1365,11 +1365,11 @@
},
"CFA": {
"text": "Coastal Flood Watch",
"priority": 4
"priority": 3
},
"FLA": {
"text": "Flood Watch",
"priority": 2
"priority": 3
},
"HWA": {
"text": "High Wind Watch",
@ -1389,7 +1389,7 @@
},
"SVA": {
"text": "Severe Thunderstorm Watch",
"priority": 4
"priority": 5
},
"TOA": {
"text": "Tornado Watch",
@ -1405,7 +1405,7 @@
},
"TSA": {
"text": "Tsunami Watch",
"priority": 4
"priority": 5
},
"TSW": {
"text": "Tsunami Warning",

View file

@ -1,13 +1,13 @@
{
"convective": [
"https://www.spc.noaa.gov/products/outlook/day1otlk.gif",
"https://www.spc.noaa.gov/products/outlook/day2otlk.gif",
"https://www.spc.noaa.gov/products/outlook/day3otlk.gif",
"https://www.spc.noaa.gov/products/exper/day4-8/day4prob.gif",
"https://www.spc.noaa.gov/products/exper/day4-8/day5prob.gif",
"https://www.spc.noaa.gov/products/exper/day4-8/day6prob.gif",
"https://www.spc.noaa.gov/products/exper/day4-8/day7prob.gif",
"https://www.spc.noaa.gov/products/exper/day4-8/day8prob.gif"
"https://weather.cod.edu/cdata/text/images/spc/co/day1/categorical/spccoday1.categorical.latest.png",
"https://climate.cod.edu/data/text/images/spc/co/day2/categorical/spccoday2.categorical.latest.png",
"https://climate.cod.edu/data/text/images/spc/co/day3/categorical/spccoday3.categorical.latest.png",
"https://climate.cod.edu/data/text/images/spc/co/day4/severe/spccoday4.severe.latest.png",
"https://climate.cod.edu/data/text/images/spc/co/day5/severe/spccoday5.severe.latest.png",
"https://climate.cod.edu/data/text/images/spc/co/day6/severe/spccoday6.severe.latest.png",
"https://climate.cod.edu/data/text/images/spc/co/day7/severe/spccoday7.severe.latest.png",
"https://climate.cod.edu/data/text/images/spc/co/day8/severe/spccoday8.severe.latest.png"
],
"fire": [
"https://www.spc.noaa.gov/products/exper/fire_wx/imgs/day1otlk_fire.gif",

View file

@ -590,5 +590,11 @@
"fgf": {
"room": "fgfchat",
"location": "Grand_Forks"
},
"hawaii": {
"room": "hawaii",
"location": "Hawaii"
}
}

View file

@ -1,3 +1,4 @@
const geolib = require("geolib");
// Use OSM API to get coordinates https://nominatim.openstreetmap.org/search?q=search+query&format=json&limit=1
const getCoordinates = async (location) => {
return new Promise((resolve, reject) => {

370
index.js
View file

@ -3,17 +3,19 @@ const fs = require("fs");
const config = require("./config.json");
const funcs = require("./funcs.js");
const wfos = require("./data/wfos.json");
const blacklist = require("./data/blacklist.json");
const events = require("./data/events.json");
const outlookURLs = require("./data/outlook.json");
const satellites = require("./data/satellites.json");
const nwrstreams = { callsigns: {} };
const nwrstreams = {callsigns:{}};
const Jimp = require("jimp");
const { client, xml } = require("@xmpp/client");
const fetch = require("node-fetch");
const html = require("html-entities")
const Discord = require("discord.js");
const dVC = require("@discordjs/voice");
const colors = require("colors");
const sqlite3 = require("sqlite3").verbose();
const WebSocket = require('ws');
satMessages = {};
@ -258,7 +260,8 @@ var iem = []
var startup = true;
var startTimestap = new Date();
var messages = 0;
var errCount = 0;
const curUUID = generateUUID();
// nwrstreams setup
@ -279,91 +282,137 @@ const fetchNWRstreams = () => {
fetchNWRstreams();
setInterval(fetchNWRstreams, 5 * 60 * 1000); // Every 5 minutes
const xmpp = client({
service: "xmpp://conference.weather.im",
domain: "weather.im",
resource: `discord-weather-bot-${generateRandomString({ upper: true, lower: true, number: true }, 5)}`, // Weird fix to "Username already in use"
});
/* We are replacing the xmpp client with a websocket.
The new data will look something like this
{
type: 'iem-message',
data: {
channel: { room: 'botstalk', location: 'All_Bots_Talk' },
event: { priority: 1, text: 'Marine Weather Message', code: 'MWW' },
body: 'LCH updates Small Craft Advisory (expires Coastal waters from Cameron LA to High Island TX out 20 NM, Coastal waters from Intracoastal City to Cameron LA out 20 NM, Coastal waters from Lower Atchafalaya River to Intracoastal City LA out 20 NM [GM], continues Waters from Cameron LA to High Island TX from 20 to 60 NM, Waters from Intracoastal City to
Cameron LA from 20 to 60 NM, Waters from Lower Atchafalaya River to Intracoastal City LA from 20 to 60 NM [GM]) . ',
timestamp: '2025-04-20T15:12:22.000Z',
wmo: 'WHUS74',
pil: 'MWWLCH',
station: 'KLCH',
raw: '202504201512-KLCH-WHUS74-MWWLCH',
rawBody: 'LCH updates Small Craft Advisory (expires Coastal waters from Cameron LA to High Island TX out 20 NM, Coastal waters from Intracoastal City to Cameron LA out 20 NM, Coastal waters from Lower Atchafalaya River to Intracoastal City LA out 20 NM [GM], continues Waters from Cameron LA to High Island TX from 20 to 60 NM, Waters from Intracoastal City
to Cameron LA from 20 to 60 NM, Waters from Lower Atchafalaya River to Intracoastal City LA from 20 to 60 NM [GM]) . https://mesonet.agron.iastate.edu/vtec/f/2025-O-EXP-KLCH-SC-Y-0028_2025-04-20T15:12Z',
image: 'https://mesonet.agron.iastate.edu/plotting/auto/plot/208/network:WFO::wfo:LCH::year:2025::phenomenav:SC::significancev:Y::etn:28::valid:2025-04-20%201512::_r:86.png',
productText: '570 \n' +
'WHUS74 KLCH 201512\n' +
'MWWLCH\n' +
'\n' +
'URGENT - MARINE WEATHER MESSAGE\n' +
'National Weather Service Lake Charles LA\n' +
'1012 AM CDT Sun Apr 20 2025\n' +
'\n' +
'GMZ450-452-455-201615-\n' +
'/O.EXP.KLCH.SC.Y.0028.000000T0000Z-250420T1500Z/\n' +
'Coastal waters from Cameron LA to High Island TX out 20 NM-\n' +
'Coastal waters from Intracoastal City to Cameron LA out 20 NM-\n' +
'Coastal waters from Lower Atchafalaya River to Intracoastal City\n' +
'LA out 20 NM-\n' +
'1012 AM CDT Sun Apr 20 2025\n' +
'\n' +
'...SMALL CRAFT ADVISORY HAS EXPIRED...\n' +
'\n' +
'$$\n' +
'\n' +
'GMZ470-472-475-202100-\n' +
'/O.CON.KLCH.SC.Y.0028.000000T0000Z-250420T2100Z/\n' +
'Coastal waters from Cameron LA to High Island TX from 20 to 60 NM-\n' +
'Coastal waters from Intracoastal City to Cameron LA from 20 to\n' +
'60 NM-\n' +
'Coastal waters from Lower Atchafalaya River to Intracoastal City\n' +
'LA from 20 to 60 NM-\n' +
'1012 AM CDT Sun Apr 20 2025\n' +
'\n' +
'...SMALL CRAFT ADVISORY REMAINS IN EFFECT UNTIL 4 PM CDT THIS\n' +
'AFTERNOON...\n' +
'\n' +
'* WHAT...Seas 4 to 7 ft.\n' +
'\n' +
'* WHERE...Coastal waters from Cameron LA to High Island TX from \n' +
' 20 to 60 NM, Coastal waters from Intracoastal City to Cameron \n' +
' LA from 20 to 60 NM and Coastal waters from Lower Atchafalaya \n' +
' River to Intracoastal City LA from 20 to 60 NM.\n' +
'\n' +
'* WHEN...Until 4 PM CDT this afternoon.\n' +
'\n' +
'* IMPACTS...Conditions will be hazardous to small craft.\n' +
'\n' +
'PRECAUTIONARY/PREPAREDNESS ACTIONS...\n' +
'\n' +
'Inexperienced mariners, especially those operating smaller\n' +
'vessels, should avoid navigating in hazardous conditions.\n' +
'\n' +
'&&\n' +
'\n' +
'$$\n'
//debug(xmpp, true);
xmpp.on("error", (err) => {
console.log(`${colors.red("[ERROR]")} XMPP Error: ${err}. Trying to reconnect...`);
setTimeout(() => {
xmpp.stop().then(() => {
start();
});
}, 5000);
});
xmpp.on("offline", () => {
console.log(`${colors.yellow("[WARN]")} XMPP offline, trying to reconnect...`);
xmpp.disconnect().then(() => {
xmpp.stop().then(() => {
start();
})
})
});
var restartTimer = null;
xmpp.on("stanza", (stanza) => {
// Debug stuff
if (config.debug >= 2) console.log(`${colors.magenta("[DEBUG]")} Stanza: ${stanza.toString()}`);
// Handle Room List
if (stanza.is("iq") && stanza.attrs.type === "result" && stanza.getChild("query")) {
query = stanza.getChild("query");
if (query.attrs.xmlns === "http://jabber.org/protocol/disco#items") {
query.getChildren("item").forEach((item) => {
// Check if the JID is on the blacklist, if so, ignore it
if (blacklist.includes(item.attrs.jid)) return;
// get proper name from wfos
const wfo = getWFOByRoom(item.attrs.jid.split("@")[0]);
item.attrs.properName = wfo.location;
iem.push(item.attrs);
console.log(`${colors.cyan("[INFO]")} Found room: ${item.attrs.jid}`);
// Join the room
//xmpp.send(xml("presence", { to: `${channel.jid}/${channel.name}/${curUUID}` }, xml("item", { role: "visitor" })));
xmpp.send(xml("presence", { to: `${item.attrs.jid}/${curUUID}` }, xml("item", { role: "visitor" })));
});
}
}
}
// Get new messages and log them, ignore old messages
if (stanza.is("message") && stanza.attrs.type === "groupchat") {
clearTimeout(restartTimer)
restartTimer = setTimeout(() => {
console.log(`${colors.red("[FATAL]")} No messages from weather.im in 10 minutes, restarting!!!!!!!!!!!`)
process.exit(1)
}, 600000)
// Stops spam from getting old messages
if (startup) return;
// Get channel name
fromChannel = stanza.attrs.from.split("@")[0];
// Ignores
if (!stanza.getChild("x")) return; // No PID, ignore it
if (!stanza.getChild("x").attrs.product_id) return;
Image and product text are optional, but if they exist, they will be in the data object.
const product_id = parseProductID(stanza.getChild("x").attrs.product_id);
const product_id_raw = stanza.getChild("x").attrs.product_id;
// Get body of message
const body = html.decode(stanza.getChildText("body"));
const bodyData = getFirstURL(body);
// get product id from "x" tag
var evt = events[product_id.pil.substring(0, 3)];
*/
if (!evt) {
evt = { name: "Unknown", priority: 3 }
console.log(`${colors.red("[ERROR]")} Unknown event type: ${product_id.pil.substring(0, 3)}. Fix me`);
console.log(`${colors.magenta("[DEBUG]")} ${bodyData.string}`)
const logChannel = discord.guilds.cache.get(config.discord.mainGuild).channels.cache.get(config.discord.logChannel);
logChannel.send({
embeds: [
{
title: "Unknown Event Type",
description: `Unknown event type: ${product_id.pil.substring(0, 3)}. Please check the logs for more details.`,
color: 0xff0000
}
]
});
}
const handleDiscordGuilds = function (data) {
const rawBody = data.data.body;
const text = data.data.productText;
const product_id_raw = data.data.raw
const product_id = data.product_data;
const fromChannel = data.data.channel.room;
const bodyData = data.data.body;
evt.code = product_id.pil.substring(0, 3);
// Check timestamp, if not within 3 minutes, ignore it
const now = new Date();
const diff = (now - product_id.timestamp) / 1000 / 60;
if (diff > 3) return;
// if (config.debug >= 1) console.log(`${colors.magenta("[DEBUG]")} New message from ${fromChannel}`);
console.log(`${colors.cyan("[INFO]")} ${getWFOByRoom(fromChannel).location} - ${evt.text} - ${product_id.timestamp}`);
messages++;
// Handle NTFY
if (config.ntfy.enabled) {
//if (config.debug >= 1) console.log(`${colors.magenta("[DEBUG]")} Sending NTFY for ${config.ntfy.prefix}${fromChannel}`)
ntfyBody = {
"topic": `${config.ntfy.prefix}${fromChannel}`,
"message": bodyData.string,
"tags": [`Timestamp: ${product_id.timestamp}`, `Station: ${product_id.station}`, `WMO: ${product_id.wmo}`, `PIL: ${product_id.pil}`, `Channel: ${fromChannel}`],
"priority": evt.priority,
"actions": [{ "action": "view", "label": "Product", "url": bodyData.url }, { "action": "view", "label": "Product Text", "url": `https://mesonet.agron.iastate.edu/api/1/nwstext/${product_id_raw}` }]
}
if (stanza.getChild("x").attrs.twitter_media) {
ntfyBody.attach = stanza.getChild("x").attrs.twitter_media;
}
fetch(config.ntfy.server, {
method: 'POST',
body: JSON.stringify(ntfyBody),
headers: {
'Authorization': `Bearer ${config.ntfy.token}`
}
}).then((res) => {
//if (config.debug >= 1) console.log(`${colors.magenta("[DEBUG]")} NTFY sent for ${config.ntfy.prefix}${fromChannel} with status ${res.status} ${res.statusText}`);
if (res.status !== 200) console.log(`${colors.red("[ERROR]")} NTFY failed for ${config.ntfy.prefix}${fromChannel} with status ${res.status} ${res.statusText}`);
}).catch((err) => {
console.error(err)
})
}
// Send discord msg
let embed = {
description: `<t:${product_id.timestamp / 1000}:T> <t:${product_id.timestamp / 1000}:R> ${bodyData.string}`,
color: parseInt(config.priorityColors[evt.priority].replace("#", ""), 16) || 0x000000,
@ -372,9 +421,9 @@ const handleDiscordGuilds = function (data) {
text: `Station: ${product_id.station} PID: ${product_id_raw} Channel: ${fromChannel}`
}
}
if (data.data.image) {
if (stanza.getChild("x").attrs.twitter_media) {
embed.image = {
url: data.data.image
url: stanza.getChild("x").attrs.twitter_media
}
}
@ -423,6 +472,11 @@ const handleDiscordGuilds = function (data) {
let channel = discord.channels.cache.get(row.channelid);
if (!channel) return console.log(`${colors.red("[ERROR]")} Channel ${row.channelid} not found`);
// fetch the product text
trySend = () => {
fetch(`https://mesonet.agron.iastate.edu/api/1/nwstext/${product_id_raw}`).then((res) => {
// If neither the body nor the product text contains the filter, ignore it
res.text().then((text) => {
if (!filters.some((filter) => body.toLowerCase().includes(filter)) && !filters.some((filter) => text.toLowerCase().includes(filter))) return;
thisMsg = JSON.parse(JSON.stringify(discordMsg));
thisMsg.content = row.custommessage || null;
@ -430,7 +484,7 @@ const handleDiscordGuilds = function (data) {
console.error(err);
}).then((msg) => {
if (msg.channel.type === Discord.ChannelType.GuildAnnouncement) msg.crosspost();
}).catch(() => {
}).catch((err) => {
console.log(`${colors.yellow("[WARN]")} Failed to send message to ${channel.guild.name}/${channel.name} (${channel.guild.id}/${channel.id})`);
const logChannel = discord.guilds.cache.get(config.discord.mainGuild).channels.cache.get(config.discord.logChannel);
logChannel.send({
@ -480,6 +534,15 @@ const handleDiscordGuilds = function (data) {
});
});
});
}).catch((err) => {
setTimeout(() => {
console.log(`${colors.red("[ERROR]")} Failed to fetch product text, retrying... ${err}`)
trySend();
})
});
}
trySend();
});
});
@ -504,6 +567,11 @@ const handleDiscordGuilds = function (data) {
let user = discord.users.cache.get(row.userid);
if (!user) return console.log(`${colors.red("[ERROR]")} User ${row.userid} not found`);
// fetch the product text
trySend = () => {
fetch(`https://mesonet.agron.iastate.edu/api/1/nwstext/${product_id_raw}`).then((res) => {
// If neither the body nor the product text contains the filter, ignore it
res.text().then((text) => {
if (!filters.some((filter) => body.toLowerCase().includes(filter)) && !filters.some((filter) => text.toLowerCase().includes(filter))) return;
thisMsg = JSON.parse(JSON.stringify(discordMsg));
thisMsg.content = row.custommessage || null;
@ -532,67 +600,22 @@ const handleDiscordGuilds = function (data) {
})
});
});
});
};
var retries = 0;
function connectWebSocket() {
console.log('Attempting to connect to WebSocket...');
const ws = new WebSocket(config.WS_URL || "wss://iem-alerter.ko4wal.radio/iem");
ws.on('open', () => {
console.log('Connected to WebSocket');
ws.send(JSON.stringify({ type: 'subscribe', channel: '*' })); // Subscribe to all channels
retries = 0; // Reset retries on successful connection
});
ws.on('message', (data) => {
try {
const data = JSON.parse(data);
switch (data.type) {
case "iem-message":
handleDiscordGuilds(data);
handleDiscordDMs(data);
break;
case "internal-response":
if (data.code == 200) {
console.log(`${colors.cyan("[INFO]")} ${data.data.message}`);
} else {
console.log(`${colors.red(`[ERROR] ${data.code}`)} ${data.data.error}`);
}).catch((err) => {
setTimeout(() => {
console.log(`${colors.red("[ERROR]")} Failed to fetch product text, retrying... ${err}`)
trySend();
})
});;
}
break;
case "connection-info":
console.log(`${colors.cyan("[INFO]")} Connected to ${data.host} (${data.uuid})`);
iem = data.iem;
trySend();
});
});
}
} catch (err) {
console.log(`${colors.red("[ERROR]")} Error parsing WebSocket message: ${err}`);
}
});
});
ws.on('close', () => {
console.log('WebSocket connection closed. Reconnecting...');
retries++;
const retryDelay = Math.min(5000, Math.pow(2, retries) * 100);
console.log(`Retrying connection in ${retryDelay} ms...`);
setTimeout(connectWebSocket, retryDelay);
});
ws.on('error', (err) => {
console.error('WebSocket error:', err);
ws.close(); // Ensure the connection is closed before retrying
});
}
connectWebSocket();
// START DISCORD
discord.on('ready', async () => {
console.log(`${colors.cyan("[INFO]")} Logged in as ${discord.user.tag}`);
xmpp.on("online", async (address) => {
if (config["uptime-kuma"].enabled) {
fetch(config["uptime-kuma"].url).then(() => {
console.log(`${colors.cyan("[INFO]")} Sent heartbeat to Uptime Kuma`)
@ -605,6 +628,73 @@ discord.on('ready', async () => {
}, config["uptime-kuma"].interval * 1000) // Every X seconds
}
errCount = 0;
// Start listening on all channels, (dont ban me funny man)
// for (const channel in iem) {
// console.log(`Joining ${channel.name}`)
// await xmpp.send(xml("presence", { to: `${channel.jud}/${channel.name}` }));
// }
/* sub format
<presence to="botstalk@conference.weather.im/add9b8f1-038d-47ed-b708-6ed60075a82f" xmlns="jabber:client">
<x xmlns="http://jabber.org/protocol/muc#user">
<item>
<role>visitor</role>
</item>
</x>
</presence>
*/
// Request room list
// Automatically find room list
xmpp.send(xml("iq", { type: "get", to: "conference.weather.im", id: "rooms" }, xml("query", { xmlns: "http://jabber.org/protocol/disco#items" })));
// Join all channels (Old method)
// iem.forEach((channel => {
// console.log(`${colors.cyan("[INFO]")} Joining ${channel.jid}/${channel.name}/${curUUID}`)
// //xmpp.send(xml("presence", { to: `${channel.jid}/${channel.jid.split("@")[0]}` }));
// xmpp.send(xml("presence", { to: `${channel.jid}/${channel.name}/${curUUID}` }, xml("item", { role: "visitor" })));
// }))
console.log(`${colors.cyan("[INFO]")} Connected to XMPP server as ${address.toString()}`);
setTimeout(() => {
startup = false;
console.log(`${colors.cyan("[INFO]")} Startup complete, listening for messages...`);
}, 1000)
});
xmpp.on("close", () => {
console.log(`${colors.yellow("[WARN]")} XMPP connection closed, trying to reconnect...`);
xmpp.disconnect().then(() => {
xmpp.stop().then(() => {
start();
})
})
})
const start = () => {
startup = true;
xmpp.start().catch((err) => {
errCount++;
if (errCount >= 5) {
console.log(`${colors.red("[ERROR]")} XMPP failed to start after 5 attempts, exiting...`);
process.exit(1);
}
console.log(`${colors.red("[ERROR]")} XMPP failed to start: ${err}.`);
xmpp.disconnect().then(() => {
xmpp.stop().then(() => {
start();
})
})
});
}
// END XMPP
// START DISCORD
discord.on('ready', async () => {
console.log(`${colors.cyan("[INFO]")} Logged in as ${discord.user.tag}`);
// Get all guilds, and log them
discord.guilds.cache.forEach((guild) => {
console.log(`${colors.cyan("[INFO]")} In guild: ${guild.name} (${guild.id})`);