const config = require("./config.json"); const { REST, Routes } = require('discord.js'); const rest = new REST({ version: '10' }).setToken(config.discord.token); const sqlite3 = require("sqlite3").verbose(); // Setup SQlite DB const db = new sqlite3.Database("channels.db", (err) => { if (err) { console.log(`${colors.red("[ERROR]")} Error connecting to database: ${err.message}`); } console.log(`${colors.cyan("[INFO]")} Connected to the database`); // Create tables if they dont exist db.run(`CREATE TABLE IF NOT EXISTS channels (channelid TEXT, iemchannel TEXT, custommessage TEXT, minPriority INTEGER, "filter" TEXT, filterevt TEXT);`); db.run(`CREATE TABLE IF NOT EXISTS userAlerts (userid TEXT, iemchannel TEXT, filter TEXT, filterEvt TEXT, minPriority INT, custommessage TEXT);`); }); // Requires const fs = require("fs"); 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 sattelites = require("./data/sattelites.json"); const nwrstreams = require("./data/nwrstreams.json") 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 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] }; } // 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 }; } // Voice funcs function JoinChannel(channel, track, volume, message) { connection = dVC.joinVoiceChannel({ channelId: channel.id, guildId: channel.guild.id, adapterCreator: channel.guild.voiceAdapterCreator, selfDeaf: true }); resource = dVC.createAudioResource(track, { inlineVolume: true, silencePaddingFrames: 5 }); player = dVC.createAudioPlayer(); connection.player = player; // So we can access it later to pause/play/stop etc resource.volume.setVolume(volume); connection.subscribe(player) player.play(resource); connection.on(dVC.VoiceConnectionStatus.Ready, () => { player.play(resource); }) connection.on(dVC.VoiceConnectionStatus.Disconnected, async (oldState, newState) => { try { await Promise.race([ dVC.entersState(connection, VoiceConnectionStatus.Signalling, 5_000), dVC.entersState(connection, VoiceConnectionStatus.Connecting, 5_000), ]); } catch (error) { message.channel.send(`Failed to reconnect to the voice channel. Stopping for now.`); connection.destroy(); return false; } }); player.on('error', error => { console.error(`Error: ${error.message} with resource ${error.resource.metadata.title}`); message.channel.send(`Error while streaming. Stopping for now.`); player.stop(); }); player.on(dVC.AudioPlayerStatus.Playing, () => { message.channel.send(`Playing stream in <#${channel.id}>`); connection.paused = false; }); player.on('idle', () => { message.channel.send(`Stream idle.`); }) return true; } function LeaveVoiceChannel(channel) { // Get resource, player, etc, and destroy them const connection = dVC.getVoiceConnection(channel.guild.id); if (connection) { connection.destroy(); return true } return false } function toggleVoicePause(channel) { const connection = dVC.getVoiceConnection(channel.guild.id); if (connection) { if (connection.paused) { connection.player.unpause(); connection.paused = false; return true; } else { connection.player.pause(); connection.paused = true; return true } } else { return false; } }; function setVolume(channel, volume) { const connection = dVC.getVoiceConnection(channel.guild.id); if (connection) { connection.player.state.resource.volume.setVolume(volume); return true; } } // 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 messages = 0; var errCount = 0; const curUUID = generateUUID(); 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(); }) }) }); 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") { // 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}`) } 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}`); 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: ` ${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_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((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; // 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; // set channelid var in shard channelid = row.channelid; manager.broadcast({ type: "sendMsgChannel", channel: channelid, msg: thisMsg }) }); }).catch((err) => { setTimeout(() => { console.log(`${colors.red("[ERROR]")} Failed to fetch product text, retrying... ${err}`) trySend(); }) }); } trySend(); }); }); // User DM alert handling db.all(`SELECT * FROM userAlerts WHERE iemchannel = ?`, [fromChannel], (err, rows) => { if (err) { console.error(err.message); } if (!rows) return; // No users to alert rows.forEach((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 priority is less than the min priority, ignore it 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; // 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; // set channelid var in shard userid = row.userid; manager.broadcast({ type: "sendMsgUser", user: userid, msg: thisMsg }) }); }).catch((err) => { setTimeout(() => { console.log(`${colors.red("[ERROR]")} Failed to fetch product text, retrying... ${err}`) trySend(); }) });; } trySend(); }); }); } }); 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 } 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 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; 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 process.on("unhandledRejection", (error, promise) => { console.log(`${colors.red("[ERROR]")} Unhandled Rejection @ ${JSON.stringify(promise)}: ${error.stack}`); // create errors folder if it doesnt exist if (!fs.existsSync("./error")) { fs.mkdirSync("./error"); } // write ./error/rejection_timestamp.txt fs.writeFileSync(`./error/rejection_${Date.now()}.txt`, `ERROR:\n${error.stack}\n\nPROMISE:\n${JSON.stringify(promise)}`); return; }); process.on("uncaughtException", (error) => { console.log(`${colors.red("[ERROR]")} Uncaught Exception: ${error.message}\n${error.stack}`); if (!fs.existsSync("./error")) { fs.mkdirSync("./error"); } // write ./error/exception_timestamp.txt fs.writeFileSync(`./error/exception_${Date.now()}.txt`, error.stack); return; }); const { ShardingManager } = require('discord.js'); const { ClusterManager } = require('discord-hybrid-sharding'); const manager = new ClusterManager(`${__dirname}/index.js`, { totalShards: 2, // or numeric shard count /// Check below for more options shardsPerClusters: 1, // 2 shards per process totalClusters: 1, mode: 'process', // you can also choose "worker" token: config.discord.token, }); manager.on('clusterCreate', (cluster) => { setInterval(() => {cluster.send({ type: "yourId", data: cluster.id})}, 1000); }) manager.spawn({timeout: -1}).then(() => { start(); console.log(`${colors.cyan("[INFO]")} Spawned all shards`); setInterval(() => { manager.broadcast({ type: "heartbeat" }); manager.broadcast({ type: "updateRooms", rooms: iem}) }, 1000) });