556 lines
18 KiB
JavaScript
556 lines
18 KiB
JavaScript
require('dotenv').config();
|
|
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 app = express();
|
|
expressWs(app);
|
|
|
|
// Serve static files from the "public" directory
|
|
app.use(express.static('public'));
|
|
global.wsConnections = [];
|
|
|
|
// IEM WebSocket
|
|
app.ws('/iem', (ws, req) => {
|
|
console.log(`connection from ${req.ip}`);
|
|
const hostname = os.hostname();
|
|
|
|
ws.send(JSON.stringify({
|
|
"type": "connection-info",
|
|
"state": true,
|
|
"uuid": curUUID,
|
|
"host": hostname
|
|
}));
|
|
wsConnections.push(ws);
|
|
ws.on('close', () => {
|
|
console.log(`disconnected from ${req.ip}`);
|
|
wsConnections = wsConnections.filter((conn) => conn !== ws);
|
|
});
|
|
ws.on('message', (msg) => {
|
|
if (msg === "ping") {
|
|
ws.send("pong")
|
|
}
|
|
});
|
|
});
|
|
|
|
// 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 number of unique channels in the database
|
|
const getUniqueChannels = function () {
|
|
return new Promise((resolve, reject) => {
|
|
db.all(`SELECT DISTINCT channelid FROM channels`, (err, rows) => {
|
|
if (err) {
|
|
console.error(err.message);
|
|
}
|
|
// Go through channels. and get number of unique guilds
|
|
const guilds = [];
|
|
rows.forEach((row) => {
|
|
const channel = discord.channels.cache.get(row.channelid);
|
|
if (!channel) return;
|
|
if (!guilds.includes(channel.guild.id)) {
|
|
guilds.push(channel.guild.id);
|
|
}
|
|
});
|
|
|
|
resolve({ channels: rows.length, guilds: guilds.length });
|
|
});
|
|
});
|
|
}
|
|
|
|
// 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) {
|
|
code = code.toLowerCase();
|
|
if (wfos[code]) {
|
|
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();
|
|
})
|
|
})
|
|
});
|
|
|
|
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.yellow("[WARN]")} Restarting XMPP connection...`);
|
|
xmpp.disconnect().then(() => {
|
|
xmpp.start();
|
|
});
|
|
}, 10000)
|
|
// 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)];
|
|
|
|
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
|
|
}
|
|
]
|
|
});
|
|
}
|
|
|
|
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,
|
|
// timestamp: product_id.timestamp,
|
|
// footer: {
|
|
// text: `Station: ${product_id.station} PID: ${product_id_raw} Channel: ${fromChannel}`
|
|
// }
|
|
// }
|
|
// if (stanza.getChild("x").attrs.twitter_media) {
|
|
// embed.image = {
|
|
// url: stanza.getChild("x").attrs.twitter_media
|
|
// }
|
|
// }
|
|
|
|
// let discordMsg = {
|
|
// embeds: [embed],
|
|
// components: [
|
|
// {
|
|
// type: 1,
|
|
// components: [
|
|
// {
|
|
// type: 2,
|
|
// label: "Product",
|
|
// style: 5,
|
|
// url: bodyData.url
|
|
// },
|
|
// {
|
|
// type: 2,
|
|
// style: 1,
|
|
// custom_id: `product|${product_id_raw}`,
|
|
// label: "Product Text",
|
|
// emoji: {
|
|
// name: "📄"
|
|
// }
|
|
// }
|
|
// ]
|
|
// }
|
|
// ]
|
|
// }
|
|
// // Discord Channel Handling
|
|
// db.all(`SELECT * FROM channels WHERE iemchannel = ?`, [fromChannel], (err, rows) => {
|
|
// if (err) {
|
|
// console.log(`${colors.red("[ERROR]")} ${err.message}`);
|
|
// }
|
|
// if (!rows) return; // No channels to alert
|
|
// rows.forEach(async (row) => {
|
|
// // Get Filters as arrays
|
|
// if (!row.filterEvt) row.filterEvt = "";
|
|
// if (!row.filter) row.filter = "";
|
|
// let filterEvt = row.filterEvt.toLowerCase().split(",");
|
|
// let filters = row.filter.toLowerCase().split(",");
|
|
// if (evt.priority < row.minPriority) return;
|
|
// // If the event type is not in th filter, ignore it. Make sure filterEvt isnt null
|
|
// if (!filterEvt[0]) filterEvt = [];
|
|
// if (!filterEvt.includes(evt.code.toLowerCase()) && !filterEvt.length == 0) return;
|
|
|
|
// 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;
|
|
// channel.send(thisMsg).catch((err) => {
|
|
// console.error(err);
|
|
// }).then((msg) => {
|
|
// if (msg.channel.type === Discord.ChannelType.GuildAnnouncement) msg.crosspost();
|
|
// }).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({
|
|
// embeds: [
|
|
// {
|
|
// title: "Failed to send message",
|
|
// description: `There is likely an issue with permissions. Please notify the server owner if possible.
|
|
// Guild: ${channel.guild.name} (${channel.guild.id})
|
|
// Channel: ${channel.name} (${channel.id})
|
|
// Guild Owner: <@${channel.guild.ownerId}> (${channel.guild.ownerId})
|
|
// Sub Info: \`\`\`json\n${JSON.stringify(row)}\`\`\``,
|
|
// color: 0xff0000
|
|
// }
|
|
// ]
|
|
// });
|
|
|
|
// discord.users.fetch(channel.guild.ownerId).then((user) => {
|
|
// user.send({
|
|
// embeds: [
|
|
// {
|
|
// title: "Issue with your subscribed channel.",
|
|
// description: `There is likely an issue with permissions. Please check that I can send messages in <#${channel.id}>\nYour subscription has been removed, and you will need to resubscribe to get alerts.`,
|
|
// color: 0xff0000,
|
|
// fields: [
|
|
// {
|
|
// name: "Guild",
|
|
// value: `${channel.guild.name} (${channel.guild.id})`
|
|
// },
|
|
// {
|
|
// name: "Channel",
|
|
// value: `${channel.name} (${channel.id})`
|
|
// }
|
|
// ]
|
|
// }
|
|
// ]
|
|
// }).catch((err) => {
|
|
// console.log(`${colors.red("[ERROR]")} Failed to send message to ${channel.guild.ownerId}`);
|
|
// }).then(() => {
|
|
// if (channel.guildId == config.discord.mainGuild) return;
|
|
// db.run(`DELETE FROM channels WHERE channelid = ? AND iemchannel = ?`, [channel.id, fromChannel], (err) => {
|
|
// if (err) {
|
|
// console.error(err.message);
|
|
// }
|
|
// console.log(`${colors.cyan("[INFO]")} Deleted channel ${channel.id} from database`);
|
|
// });
|
|
// })
|
|
// });
|
|
// });
|
|
// });
|
|
// }).catch((err) => {
|
|
// setTimeout(() => {
|
|
// console.log(`${colors.red("[ERROR]")} Failed to fetch product text, retrying... ${err}`)
|
|
// trySend();
|
|
// })
|
|
// });
|
|
// }
|
|
// trySend();
|
|
// });
|
|
// });
|
|
|
|
|
|
// Handle WebSocket
|
|
if (wsConnections.length > 0) {
|
|
wsConnections.forEach((ws) => {
|
|
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
|
|
}
|
|
}));
|
|
});
|
|
}
|
|
}
|
|
});
|
|
|
|
const createDiscordEmbed = (data) => {
|
|
const embed = {
|
|
description: `<t:${Math.floor(data.timestamp / 1000)}:T> <t:${Math.floor(data.timestamp / 1000)}:R> ${data.body}`,
|
|
color: parseInt(config.priorityColors[data.event.priority].replace("#", ""), 16) || 0x000000,
|
|
timestamp: new Date(data.timestamp).toISOString(),
|
|
footer: {
|
|
text: `Station: ${data.station} PID: ${data.raw} Channel: ${data.channel}`
|
|
}
|
|
};
|
|
|
|
if (data.image) {
|
|
embed.image = {
|
|
url: data.image
|
|
};
|
|
}
|
|
|
|
return embed;
|
|
};
|
|
|
|
|
|
|
|
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
|
|
<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) => {
|
|
console.log("BWUH")
|
|
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();
|
|
}); |