require('dotenv').config({ override: true }); const config = require("./config.json"); const express = require('express'); const expressWs = require('express-ws'); const { client, xml } = require("@xmpp/client"); const colors = require('colors'); const html = require('html-entities'); const blacklist = require("./blacklist.json") const wfos = require("./wfos.json") const events = require("./events.json") const os = require('os'); const fs = require('fs'); const path = require('path'); const markdown = require('markdown-it')(); const ejs = require('ejs'); const { connect } = require('http2'); const serveIndex = require('serve-index'); const hostname = process.env.HOST_OVERRIDE || os.hostname(); const app = express(); expressWs(app); // Serve static files from the "public" directory app.use(express.static('public')); app.use(express.json()); app.use(express.urlencoded({ extended: true })); app.set('view engine', 'ejs'); app.set('views', path.join(__dirname, 'views')); // Add ident headers app.use((req, res, next) => { res.header("Node-UUID", curUUID); res.header("Node-Hostname", hostname); next(); }); app.get("/", (req, res) => { const markdownFilePath = path.join(__dirname, 'md', 'DOCS.md'); fs.readFile(markdownFilePath, 'utf8', (err, data) => { if (err) { console.error('Error reading markdown file:', err); res.status(500).send('Internal Server Error'); return; } const htmlContent = markdown.render(data); res.render('docViewer', { title: 'Websocket Docs', htmlContent: htmlContent }); }); }); app.use('/event-logs', serveIndex(path.join(__dirname, 'event-logs'), { icons: true, hidden: true }), express.static(path.join(__dirname, 'event-logs')) ); var socketStatus; app.get('/health', (req, res) => { res.status(200).json({ status: socketStatus, connections: wsConnections.length, uptime: process.uptime(), messages: messages, errCount: errCount, startup: startup, startTime: startTimestap, uuid: curUUID, }); }); global.wsConnections = []; var roomList = []; // IEM WebSocket app.ws('/iem', (ws, req) => { console.log(req.headers) const clientIp = process.env.REALIP ? req.headers[process.env.REALIP.toLowerCase()] || req.ip : req.ip; console.log(`connection from ${clientIp}`); if (!req.headers['user-agent']) { ws.send(JSON.stringify({ "type": "internal-response", "code": 400, "data": { "error": "User-Agent header is required." } })); console.log(`${colors.red("[ERROR]")} User-Agent header is required from ${clientIp}`); ws.close(); return; } ws.send(JSON.stringify({ "type": "connection-info", "status": socketStatus, "uuid": curUUID, "host": hostname, "connections": wsConnections.length, "uptime": process.uptime(), "messages": messages, "errCount": errCount, "startup": startup, "startTime": startTimestap, "roomList": roomList, "wfos": Object.keys(wfos).map((key) => { return { code: key, location: wfos[key].location, room: wfos[key].room } }), iem })); sock = wsConnections.push({ ws, req, subs: [] }); ws.on('close', () => { console.log(`disconnected from ${clientIp}`); wsConnections = wsConnections.filter((conn) => conn.ws !== ws); }); try { ws.on('message', (msg) => { let data; try { data = JSON.parse(msg); } catch (error) { ws.send(JSON.stringify({ "type": "internal-response", "code": 400, "data": { "error": "Invalid JSON format." } })); console.log(`${colors.red("[ERROR]")} Invalid JSON format from ${clientIp}: ${msg}`); return; } if (!data.type) return ws.send(JSON.stringify({ "type": "internal-response", "code": 400, "data": { "error": "Invalid request." } })); switch (data.type) { case "subscribe": const subscriptionTarget = data.channel || getWFOroom(data.wfo) || "*"; if (subscriptionTarget === "*") { wsConnections[sock - 1].subs.push(subscriptionTarget); ws.send(JSON.stringify({ "type": "internal-response", "code": 200, "data": { "message": `Subscribed to all channels.` } })); break; } else if (!roomList.includes(subscriptionTarget)) { ws.send(JSON.stringify({ "type": "internal-response", "code": 404, "data": { "error": "Invalid channel." } })); break; } else { wsConnections[sock - 1].subs.push(subscriptionTarget); ws.send(JSON.stringify({ "type": "internal-response", "code": 200, "data": { "message": `Subscribed to ${subscriptionTarget}` } })); break; } case "unsubscribe": const unsubscribeTarget = data.channel || getWFOroom(data.wfo) || "*"; if (unsubscribeTarget === "*") { wsConnections[sock - 1].subs = wsConnections[sock - 1].subs.filter((sub) => sub !== "*"); ws.send(JSON.stringify({ "type": "internal-response", "code": 200, "data": { "message": `Unsubscribed from all channels.` } })); break; } else if (!getWFO(data.channel)) { ws.send(JSON.stringify({ "type": "internal-response", "code": 404, "data": { "error": "Invalid channel." } })); break; } else { wsConnections[sock - 1].subs = wsConnections[sock - 1].subs.filter((sub) => sub !== unsubscribeTarget); ws.send(JSON.stringify({ "type": "internal-response", "code": 200, "data": { "message": `Unsubscribed from ${unsubscribeTarget}` } })); break; } case "get-subscriptions": ws.send(JSON.stringify({ "type": "internal-response", "code": 200, "data": { "subscriptions": wsConnections[sock - 1].subs } })); break; case "room-list": if (roomList.length > 0) { ws.send(JSON.stringify({ "type": "room-list", "code": 200, "count": roomList.length, "data": roomList })); } else { ws.send(JSON.stringify({ "type": "room-list", "code": 503, "count": roomList.length, "data": { "error": "Room list is currently empty. Please try again later." } })); } break; case "ping": ws.send(JSON.stringify({ "type": "internal-response", "code": 200, "data": { "message": "pong" } })); break; default: ws.send(JSON.stringify({ "type": "internal-response", "code": 400, "data": { "error": "Invalid request." } })); break; } }); } catch (error) { console.error(error); ws.send(JSON.stringify({ "type": "internal-response", "code": 500, "data": { "error": "Internal server error." } })); } }) // Random funcs function toTitleCase(str) { return str.replace( /\w\S*/g, function (txt) { return txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase(); } ); } const parseProductID = function (product_id) { const [timestamp, station, wmo, pil] = product_id.split("-"); return { timestamp: convertDate(timestamp), originalTimestamp: timestamp, station, wmo, pil }; } // Convert date format 202405080131 (YYYYMMddHHmm) to iso format, hours and mins is UTC const convertDate = function (date) { const year = date.substring(0, 4); const month = date.substring(4, 6); const day = date.substring(6, 8); const hours = date.substring(8, 10); const mins = date.substring(10, 12); // Because they don't have seconds, assume current seconds const secs = new Date().getSeconds(); return new Date(Date.UTC(year, month - 1, day, hours, mins, secs)); } // Get first url in a string, return object {string, url} remove the url from the string const getFirstURL = function (string) { url = string.match(/(https?:\/\/[^\s]+)/g); if (!url) return { string, url: null }; const newString = string.replace(url[0], ""); return { string: newString, url: url[0] }; } // Function to get the room name from the WFO code const getWFOroom = function (code) { if (typeof code !== 'string') { return null; } code = code.toLowerCase(); if (wfos[code] && typeof wfos[code].room === 'string') { return wfos[code].room; } else { return code; } } // Function to get WFO data const getWFO = function (code) { code = code.toLowerCase(); if (wfos[code]) { return wfos[code]; } else { return null; } } // Get WFO data from room name function getWFOByRoom(room) { room = room.toLowerCase(); for (const key in wfos) { if (wfos.hasOwnProperty(key) && wfos[key].room === room) { return wfos[key]; } } return { location: room, room: room }; } // func to Generate random string, ({upper, lower, number, special}, length) const generateRandomString = function (options, length) { let result = ''; const characters = { upper: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', lower: 'abcdefghijklmnopqrstuvwxyz', number: '0123456789', special: '!@#$%^&*()_+' }; let chars = ''; for (const key in options) { if (options[key]) { chars += characters[key]; } } for (let i = 0; i < length; i++) { result += chars.charAt(Math.floor(Math.random() * chars.length)); } return result; } // Func to generate UUID const generateUUID = function () { return generateRandomString({ lower: true, upper: true, number: true }, 8) + "-" + generateRandomString({ lower: true, upper: true, number: true }, 4) + "-" + generateRandomString({ lower: true, upper: true, number: true }, 4) + "-" + generateRandomString({ lower: true, upper: true, number: true }, 4) + "-" + generateRandomString({ lower: true, upper: true, number: true }, 12); } // Variable setup var iem = [] var startup = true; var startTimestap = new Date(); var messages = 0; var errCount = 0; const curUUID = generateUUID(); // Start IEM XMPP Connection 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" }); //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(); }).catch((err) => { console.log(`${colors.red("[ERROR]")} XMPP failed to stop: ${err}. Gonna go ahead and try to reconnect...`); start(); }); }).catch((err) => { console.log(`${colors.red("[ERROR]")} XMPP failed to disconnect: ${err}. Gonna go ahead and try to reconnect...`); start(); }); }); var restartTimer = null; var sent = []; 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" }))); roomList.push(item.attrs.jid.split("@")[0]); }); } } // Get new messages and log them, ignore old messages if (stanza.is("message") && stanza.attrs.type === "groupchat") { // 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; 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)]; // Log the full event object to a file named "productid-timestamp-channelname.json" const nowDate = new Date(); const year = nowDate.getFullYear(); const month = String(nowDate.getMonth() + 1).padStart(2, '0'); const day = String(nowDate.getDate()).padStart(2, '0'); const logDir = path.join(__dirname, "event-logs", year.toString(), month, day); if (!fs.existsSync(logDir)) { fs.mkdirSync(logDir, { recursive: true }); } const logFilename = `${product_id_raw}-${product_id.timestamp.toISOString()}-${fromChannel}.json`.replace(/[:]/g, "_"); const logPath = path.join(logDir, logFilename); const getCircularReplacer = () => { const seen = new WeakSet(); return (key, value) => { if (typeof value === "object" && value !== null) { if (seen.has(value)) { return "WARN: CIRC"; } seen.add(value); } return value; }; }; if (!evt) { evt = { name: "Unknown", priority: 3 } console.log(`${colors.red("[ERROR]")} Unknown event type: ${product_id.pil.substring(0, 3)}. Fix me`); 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 } ] }); } 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} - ${product_id_raw} - ${evt.text} - ${product_id.timestamp}`); messages++; textTries = 0; tryGetText = () => { 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 (wsConnections.length > 0) { wsConnections.forEach((connection) => { if (connection.subs.includes(fromChannel) || connection.subs.includes("*")) { connection.ws.send(JSON.stringify({ "type": "iem-message", "data": { "channel": getWFOByRoom(fromChannel), "event": evt, "body": bodyData, "timestamp": product_id.timestamp, "wmo": product_id.wmo, "pil": product_id.pil, "station": product_id.station, "product_data": product_id, "raw": product_id_raw, "rawBody": body, "image": stanza.getChild("x").attrs.twitter_media || null, "productText": text || null } })); } }); } }); }).catch((err) => { setTimeout(() => { if (textTries >= 3) { console.log(`${colors.red("[ERROR]")} Failed to fetch product text, giving up... ${err}`) if (wsConnections.length > 0) { wsConnections.forEach((connection) => { if (connection.subs.includes(fromChannel) || connection.subs.includes("*")) { connection.ws.send(JSON.stringify({ "type": "iem-message", "data": { "channel": getWFOByRoom(fromChannel), "event": evt, "body": bodyData.string, "timestamp": product_id.timestamp, "wmo": product_id.wmo, "pil": product_id.pil, "station": product_id.station, "raw": product_id_raw, "rawBody": body, "image": stanza.getChild("x").attrs.twitter_media || null, "productText": null } })); } }); } } else { textTries++; console.log(`${colors.red("[ERROR]")} Failed to fetch product text, retrying... ${err}`) setTimeout(tryGetText, 100); } }) }); fs.writeFileSync(logPath, JSON.stringify({ evt, stanza, product_id, product_id_raw, bodyData, body, sentData: { "type": "iem-message", "data": { "channel": getWFOByRoom(fromChannel), "event": evt, "body": bodyData, "timestamp": product_id.timestamp, "wmo": product_id.wmo, "pil": product_id.pil, "station": product_id.station, "product_data": product_id, "raw": product_id_raw, "rawBody": body, "image": stanza.getChild("x").attrs.twitter_media || null, "productText": text || null } } }, getCircularReplacer(), 2), 'utf8'); } tryGetText(); } }); xmpp.on("status", (status) => { socketStatus = status; console.log(`${colors.cyan("[INFO]")} XMPP Status is ${status}`); // Broadcast a message to all connected WebSocket clients wsConnections.forEach((connection) => { if (connection.ws.readyState === 1) { // Ensure the socket is open connection.ws.send(JSON.stringify({ "type": "xmpp-status", "status": status })); } }); }); xmpp.reconnect.on("reconnecting", () => { console.log(`${colors.yellow("[WARN]")} XMPP Reconnecting...`); wsConnections.forEach((connection) => { if (connection.ws.readyState === 1) { // Ensure the socket is open connection.ws.send(JSON.stringify({ "type": "xmpp-reconnect", "status": "reconnecting" })); } }); }) xmpp.reconnect.on("reconnected", () => { console.log(`${colors.green("[INFO]")} XMPP Reconnected`); wsConnections.forEach((connection) => { if (connection.ws.readyState === 1) { // Ensure the socket is open connection.ws.send(JSON.stringify({ "type": "xmpp-reconnect", "status": "reconnected" })); } }); }) 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`) // }) // setInterval(() => { // // Send POST request to config["uptime-kuma"].url // fetch(config["uptime-kuma"].url).then(() => { // console.log(`${colors.cyan("[INFO]")} Sent heartbeat to Uptime Kuma`) // }) // }, config["uptime-kuma"].interval * 1000) // Every X seconds // } // 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 visitor */ // 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; if (xmpp.status !== "offline") { console.log(`${colors.yellow("[WARN]")} XMPP is already running, stopping it...`); xmpp.stop().then(() => { console.log(`${colors.cyan("[INFO]")} XMPP stopped, starting it again...`); }).catch((err) => { console.log(`${colors.red("[ERROR]")} Failed to stop XMPP: ${err}. Trying to start anyway...`); }); } xmpp.start().catch((err) => { console.log(`${colors.red("[ERROR]")} XMPP failed to start: ${err}.`); setTimeout(start, 5000); }); } // Start Express Server const PORT = process.env.SERVER_PORT || 3000; // Start the server app.listen(PORT, () => { console.log(`Server is listening on ${PORT}`); start(); });