// Requires const fs = require("fs"); const config = require("./config.json"); const funcs = require("./funcs.js"); const wfos = require("./data/wfos.json"); const outlookURLs = require("./data/outlook.json"); const satellites = require("./data/satellites.json"); const nwrstreams = { callsigns: {} }; const Jimp = require("jimp"); 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'); const events = require("./data/events.json"); satMessages = {}; // Setup Discord const discord = new Discord.Client({ intents: [ "Guilds", "GuildVoiceStates", "DirectMessages" ] }); const { REST, Routes } = require('discord.js'); const rest = new REST({ version: '10' }).setToken(config.discord.token); // 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);`); db.run(`ALTER TABLE channels RENAME COLUMN filterevt TO filterEvt;`) }); // 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 }; } // 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 startTimestap = new Date(); var messages = 0; // nwrstreams setup // get icecast json data const fetchNWRstreams = () => { fetch("https://icestats.weatherradio.org/").then((res) => { res.json().then((json) => { json.icestats.source.forEach((source) => { nwrstreams.callsigns[source.server_name] = source.listenurl; }); }); console.log(`${colors.cyan("[INFO]")} Fetched NWR streams`); }).catch((err) => { console.error(err); }); } fetchNWRstreams(); setInterval(fetchNWRstreams, 5 * 60 * 1000); // Every 5 minutes /* 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' } } Image and product text are optional, but if they exist, they will be in the data object. */ const handleDiscord = function (data) { const rawBody = data.data.body; const text = data.data.productText; const product_id_raw = data.data.raw const product_id = data.data.product_data; const fromChannel = data.data.channel.room; const body = data.data.body; console.log(product_id_raw.substring(0, 3)) var evt = events[product_id.pil.substring(0, 3)]; evt.code = product_id.pil.substring(0, 3); console.log({product_id_raw, product_id, fromChannel, body, evt}) let embed = { description: ` ${body.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 (data.data.image) { embed.image = { url: data.data.image } } let discordMsg = { embeds: [embed], components: [ { type: 1, components: [ { type: 2, label: "Product", style: 5, url: body.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) => { console.log(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`); if (!filters.some((filter) => string.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(() => { 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`); }); }) }); }); }); }); // 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; let user = discord.users.cache.get(row.userid); if (!user) return console.log(`${colors.red("[ERROR]")} User ${row.userid} not found`); if (!filters.some((filter) => body.string.toLowerCase().includes(filter)) && !filters.some((filter) => text.toLowerCase().includes(filter))) return; thisMsg = JSON.parse(JSON.stringify(discordMsg)); thisMsg.content = row.custommessage || null; user.send(thisMsg).catch((err) => { console.error(err); }).catch((err) => { console.log(`${colors.yellow("[WARN]")} Failed to send message to ${user.tag} (${user.id})`); const logChannel = discord.guilds.cache.get(config.discord.mainGuild).channels.cache.get(config.discord.logChannel); logChannel.send({ embeds: [ { title: "Failed to send DM", description: `User may have DMs disabled, or bot doesnt' share a server anymore!. User: ${user.tag} (${user.id}) Sub Info: \`\`\`json\n${JSON.stringify(row)}\`\`\``, color: 0xff0000 } ] }).then(() => { db.run(`DELETE FROM userAlerts WHERE userid = ? AND iemchannel = ?`, [user.id, fromChannel], (err) => { if (err) { console.error(err.message); } console.log(`${colors.cyan("[INFO]")} Deleted user ${user.id} from database`); }); }) }); }); }); }; 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', (rawData) => { try { const data = JSON.parse(rawData); switch (data.type) { case "iem-message": handleDiscord(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}`); } break; case "connection-info": console.log(`${colors.cyan("[INFO]")} Connected to ${data.host} (${data.uuid})`); iem = data.iem; } } catch (err) { console.log(`${colors.red("[ERROR]")} Error parsing WebSocket message: ${err.stack}`); } }); 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}`); 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 } // Get all guilds, and log them discord.guilds.cache.forEach((guild) => { console.log(`${colors.cyan("[INFO]")} In guild: ${guild.name} (${guild.id})`); }); // Do slash command stuff commands = require("./data/commands.json"); // Add dynamic commands (based on datas files) satCommand = { "name": "satellite", "description": "Get the latest satellite images from a given satellite", "options": [ { "name": "satellite", "description": "The satellite to get images from", "type": 3, "required": true, "choices": [] } ] } for (const key in satellites) { satCommand.options[0].choices.push({ "name": key, "value": key }); } commands.push(satCommand); if (config.broadcastify.enabled) { // Add commands to join vc, leave vc, and play stream commands.push( { "name": "playbcfy", "description": "Play the broadcastify stream", "options": [ { "name": "id", "description": "The ID of the stream to play", "type": 3, "required": true } ] } ) } if (config.voice_enabled) { // Add commands to join vc, leave vc, and play stream commands.push( { "name": "leave", "description": "Leave the current voice chat", "default_member_permissions": 0 }, { "name": "play", "type": 1, "description": "Play a stream", "options": [ { "name": "url", "description": "The URL of the stream to play", "type": 3, "required": true } ] }, { "name": "pause", "description": "Pause/Unpause the current stream", "type": 1 }, { "name": "volume", "description": "Set the volume of the current stream", "options": [ { "name": "volume", "description": "The volume to set", "type": 4, "required": true } ] } ) nwrplayCommand = { "name": "nwrplay", "description": "Nwr stream", "type": 1, "options": [ { "name": "callsign", "description": "The URL of the stream to play", "type": 3, "required": true, "autocomplete": true } ] } commands.push(nwrplayCommand); } await (async () => { try { //Global if (config.debug >= 1) console.log(`${colors.magenta("[DEBUG]")} Registering global commands`); await rest.put(Routes.applicationCommands(discord.user.id), { body: commands }) } catch (error) { console.error(error); } })(); // setTimeout(() => { // // Wait 10 seconds, if startup is still true, something went wrong // if (startup) { // console.log(`${colors.red("[ERROR]")} Startup failed, exiting...`); // process.exit(1); // } // }, 10000) // Check all channels in DB, fetch them, if they dont exist, delete all subscriptions db.all(`SELECT channelid FROM channels`, (err, rows) => { if (err) { console.error(err.message); } rows.forEach((row) => { const channel = discord.channels.cache.get(row.channelid); if (!channel) { // Delete the channel from the database and return return db.run(`DELETE FROM channels WHERE channelid = ?`, [row.channelid], (err) => { if (err) { console.error(err.message); } console.log(`${colors.cyan("[INFO]")} Deleted channel ${row.channelid} from database`); }); }; }); }); // Get all users in userAlerts and fetch them db.all(`SELECT userid FROM userAlerts`, (err, rows) => { if (err) { console.error(err.message); } rows.forEach((row) => { discord.users.fetch(row.userid); }); }); }); discord.on("interactionCreate", async (interaction) => { switch (interaction.type) { case Discord.InteractionType.ApplicationCommand: switch (interaction.commandName) { case "subscribe": room = getWFOroom(interaction.options.getString("room")); if (!iem.find((channel) => channel.jid.split("@")[0] === room)) { interaction.reply({ content: "Invalid room", ephemeral: true }); return; } if (interaction.options.getString("filter")) { filter = interaction.options.getString("filter").toLowerCase(); } else { filter = ""; } minPriority = interaction.options.getInteger("minpriority"); filterEvt = interaction.options.getString("filterevt") || null; message = interaction.options.getString("message") || null; if (interaction.inGuild()) { interaction.channel.send("Permission check").then((msg) => { msg.delete(); db.get(`SELECT * FROM channels WHERE channelid = ? AND iemchannel = ?`, [interaction.channel.id, room], (err, row) => { if (err) { console.error(err.message); interaction.reply({ content: "Failed to subscribe to room", ephemeral: true }); } else if (row) { return interaction.reply({ content: `Already subscribed to \`${getWFOByRoom(room).location}\`\nIf you want to update a subscribtion, please unsubscribe and resubscribe. This will be made a command eventually.`, ephemeral: true }); } db.run(`INSERT INTO channels (channelid, iemchannel, custommessage, filter, filterEvt, minPriority) VALUES (?, ?, ?, ? ,? ,?)`, [interaction.channel.id, room, message, filter, filterEvt, minPriority], (err) => { if (err) { console.error(err.message); interaction.reply({ content: "Failed to subscribe to room", ephemeral: true }); } else { interaction.reply({ content: `Subscribed to \`${getWFOByRoom(room).location}\``, ephemeral: true }); } }); }); }).catch((err) => { interaction.reply({ content: "Failed to subscribe to room. Bot does not have send message permissions here!", ephemeral: true }); }); } else { // We're in a DM db.get(`SELECT * FROM userAlerts WHERE userid = ? AND iemchannel = ?`, [interaction.user.id, room], (err, row) => { if (err) { console.error(err.message); interaction.reply({ content: "Failed to subscribe to room", ephemeral: true }); } else if (row) { return interaction.reply({ content: `Already subscribed to \`${getWFOByRoom(room).location}\`\nIf you want to update a subscribtion, please unsubscribe and resubscribe. This will be made a command eventually.`, ephemeral: true }); } db.run(`INSERT INTO userAlerts (userid, iemchannel, custommessage, filter, filterEvt, minPriority) VALUES (?, ?, ?, ? ,?, ?)`, [interaction.user.id, room, message, filter, filterEvt, minPriority], (err) => { if (err) { console.error(err.message); interaction.reply({ content: "Failed to subscribe to room", ephemeral: true }); } else { interaction.reply({ content: `Subscribed to \`${getWFOByRoom(room).location}\``, ephemeral: true }); } }); }); } break; case "unsubscribe": // Check that the room is valid room = getWFOroom(interaction.options.getString("room")); if (!iem.find((channel) => channel.jid.split("@")[0] === room)) { interaction.reply({ content: "Invalid room", ephemeral: true }); return; } if (interaction.inGuild()) { // Check if subbed db.get(`SELECT * FROM channels WHERE channelid = ? AND iemchannel = ?`, [interaction.channel.id, room], (err, row) => { if (err) { console.error(err.message); interaction.reply({ content: "Failed to unsubscribe from room", ephemeral: true }); } if (!row) { return interaction.reply({ content: `Not subscribed to \`${getWFOByRoom(room).location}\``, ephemeral: true }); } db.run(`DELETE FROM channels WHERE channelid = ? AND iemchannel = ?`, [interaction.channel.id, room], (err) => { if (err) { console.error(err.message); interaction.reply({ content: "Failed to unsubscribe from room", ephemeral: true }); } interaction.reply({ content: `Unsubscribed from \`${getWFOByRoom(room).location}\``, ephemeral: true }); }); }); } else { db.get(`SELECT * FROM userAlerts WHERE userid = ? AND iemchannel = ?`, [interaction.user.id, room], (err, row) => { if (err) { console.error(err.message); interaction.reply({ content: "Failed to unsubscribe from room", ephemeral: true }); } if (!row) { return interaction.reply({ content: `Not subscribed to \`${getWFOByRoom(room).location}\``, ephemeral: true }); } db.run(`DELETE FROM userAlerts WHERE userid = ? AND iemchannel = ?`, [interaction.user.id, room], (err) => { if (err) { console.error(err.message); interaction.reply({ content: "Failed to unsubscribe from room", ephemeral: true }); } interaction.reply({ content: `Unsubscribed from \`${getWFOByRoom(room).location}\``, ephemeral: true }); }); }); } break; case "list": // List all the subscribed rooms if (interaction.inGuild()) { db.all(`SELECT * FROM channels WHERE channelid = ?`, [interaction.channel.id], (err, rows) => { if (err) { console.error(err.message); interaction.reply({ content: "Failed to get subscribed rooms", ephemeral: true }); } if (!rows) { return interaction.reply({ content: "No subscribed rooms", ephemeral: true }); } let roomList = []; rows.forEach((row) => { roomList.push({ name: `${row.iemchannel}: ${getWFOByRoom(row.iemchannel).location}`, value: `Message: \`\`${row.custommessage || "None"}\`\`\nFilter: \`\`${row.filter || "None"}\`\`\nEvent Filter: \`\`${row.filterEvt || "None"}\`\`\nMin Priority: \`\`${row.minPriority || "None"}\`\`` }); }); const embed = { title: "Subscribed Rooms", fields: roomList, color: 0x00ff00 } interaction.reply({ embeds: [embed], ephemeral: true }); }); } else { db.all(`SELECT * FROM userAlerts WHERE userid = ?`, [interaction.user.id], (err, rows) => { if (err) { console.error(err.message); interaction.reply({ content: "Failed to get subscribed rooms", ephemeral: true }); } if (!rows) { return interaction.reply({ content: "No subscribed rooms", ephemeral: true }); } let roomList = []; rows.forEach((row) => { roomList.push({ name: `${row.iemchannel}: ${getWFOByRoom(row.iemchannel).location}`, value: `Message: \`\`${row.custommessage || "None"}\`\`\nFilter: \`\`${row.filter || "None"}\`\`\nEvent Filter: \`\`${row.filterEvt || "None"}\`\`\nMin Priority: \`\`${row.minPriority || "None"}\`\`` }); }); const embed = { title: "Subscribed Rooms", fields: roomList, color: 0x00ff00 } interaction.reply({ embeds: [embed], ephemeral: true }); }); } break; case "about": // Send an embed showing info about the bot, including number of guilds, number of subscribed rooms, etc let guilds = discord.guilds.cache.size; let channels = 0; let uniques = 0; await db.get(`SELECT COUNT(*) as count FROM channels`, async (err, row) => { channels = row.count await getUniqueChannels().then((unique) => { uniques = unique.channels; guilds = unique.guilds; }); discord.users.fetch("289884287765839882").then((chrisUser) => { const embed = { title: "About Me!", thumbnail: { url: discord.user?.avatarURL() }, description: `I listen to all the weather.im rooms and send them to discord channels.\nI am open source, you can find my code [here!](https://github.com/ChrisChrome/iembot-2.0)\n\nThis bot is not affiliated with NOAA, the National Weather Service, or the IEM project.`, fields: [ { name: "Uptime", value: `Since , Started `, }, { name: "Caught Messages", value: `Got ${messages.toLocaleString()} messages since startup`, }, { name: "Guilds", value: guilds.toLocaleString(), inline: true }, { name: "Subscribed Rooms", value: `${channels.toLocaleString()}`, inline: true }, { name: "Unique Channels", value: `${uniques.toLocaleString()} in ${guilds} guilds.`, inline: true } ], color: 0x00ff00, footer: { text: "Made by @chrischrome with <3", icon_url: chrisUser.avatarURL() } } interaction.reply({ embeds: [embed] }); }) }); break; case "rooms": // // Send an embed showing all the available rooms // let roomList = ""; // iem.forEach((channel) => { // room = channel.jid.split("@")[0] // console.log(getWFOByRoom(room)) // roomList += `\`${room}\`: ${getWFOByRoom(room).location}\n`; // }); // const roomEmbed = { // title: "Available Rooms", // description: roomList, // color: 0x00ff00 // } // interaction.reply({ embeds: [roomEmbed], ephemeral: true }); // Do the above, but paginate like the product text let roomList = ""; iem.forEach((channel) => { room = channel.jid.split("@")[0] roomList += `\`${room}\`: ${getWFOByRoom(room).location || "Unknown"}\n`; }); const pages = roomList.match(/[\s\S]{1,2000}(?=\n|$)/g); embeds = pages.map((page, ind) => ({ title: `Available Rooms Pg ${ind + 1}/${pages.length}`, description: page, color: 0x00ff00 })); interaction.reply({ embeds, ephemeral: true }); break; case "setupall": if (!interaction.inGuild()) return interaction.reply({ content: "This command can only be used in a guild", ephemeral: true }); if (!config.discord.owner) return interaction.reply({ content: "Owner not set in config", ephemeral: true }); if (interaction.user.id !== config.discord.owner) return interaction.reply({ content: "You are not the owner", ephemeral: true }); await interaction.deferReply({ ephemeral: true }) var category; // New setup, we're pulling from wfos.json now const chunks = []; const chunkSize = 50; const total = iem.length; // wfos is object "wfo": {"location": "Text Name", "room": "roomname"} for (let i = 0; i < total; i += chunkSize) { chunks.push(iem.slice(i, i + chunkSize)); console.log(iem.slice(i, i + chunkSize)) } chunks.forEach((chunk, index) => { const categoryName = `Rooms ${index + 1}`; interaction.guild.channels.create({ name: categoryName, type: Discord.ChannelType.GuildCategory }).then((newCategory) => { console.log(`${colors.cyan("[INFO]")} Created category ${newCategory.name}`); chunk.forEach((channel) => { channelName = `${channel.jid.split("@")[0]}_${getWFOByRoom(channel.jid.split("@")[0]).location}` if (channelName == "Unknown") channelName = channel.jid.split("@")[0] newCategory.guild.channels.create({ name: channelName, type: Discord.ChannelType.GuildText, parent: newCategory, topic: `Weather.im room for ${getWFOByRoom(channel.jid.split("@")[0]).location} - ${channel.jid.split("@")[0]}` }).then((newChannel) => { console.log(`${colors.cyan("[INFO]")} Created channel ${newChannel.name}`); db.run(`INSERT INTO channels (channelid, iemchannel, custommessage) VALUES (?, ?, ?)`, [newChannel.id, channel.jid.split("@")[0], null], (err) => { if (err) { console.error(err.message); } console.log(`${colors.cyan("[INFO]")} Added channel ${newChannel.id} to database`); }); }).catch((err) => { console.log(`${colors.red("[ERROR]")} Failed to create channel: ${err.message}`); }); }); }).catch((err) => { console.log(`${colors.red("[ERROR]")} Failed to create category: ${err.message}`); }); }); interaction.editReply({ content: "Setup complete", ephemeral: true }); break; case "support": // Generate an invite link to the support server (use widget channel) const invite = await discord.guilds.cache.get(config.discord.mainGuild).channels.cache.get(config.discord.inviteChannel).createInvite(); const embed = { title: "Support Server", description: `Need help with the bot? Join the support server [here](${invite.url})`, color: 0x00ff00 } interaction.reply({ embeds: [embed] }); break; case "playbcfy": // Play broadcastify stream if (!interaction.inGuild()) return interaction.reply({ content: "This command can only be used in a guild", ephemeral: true }); if (!config.broadcastify.enabled) return interaction.reply({ content: "Broadcastify is not enabled", ephemeral: true }); streamID = interaction.options.getString("id"); // Check if the stream ID is valid (up to 10 digit alphanumeric) if (!streamID.match(/^[a-zA-Z0-9]{1,10}$/)) return interaction.reply({ content: "Invalid stream ID", ephemeral: true }); // Get the stream URL url = `https://${config.broadcastify.username}:${config.broadcastify.password}@audio.broadcastify.com/${streamID}.mp3`; // Get the channel channel = interaction.member.voice?.channel; if (!channel) return interaction.reply({ content: "You need to be in a voice channel", ephemeral: true }); // Join the channel and play the stream res = JoinChannel(channel, url, .1, interaction) if (res) { interaction.reply({ content: "Playing Stream", ephemeral: true }); } else { interaction.reply({ content: `Failed to play stream`, ephemeral: true }); } break; case "play": // Play generic stream if (!interaction.inGuild()) return interaction.reply({ content: "This command can only be used in a guild", ephemeral: true }); // Use local variables & Get the URL interactionUrl = interaction.options.getString("url"); // Get the channel channel = interaction.member.voice?.channel; // Check if in channel if (!channel) return interaction.reply({ content: "You need to be in a voice channel", ephemeral: true }); // Join the channel and play the stream st = JoinChannel(channel, interactionUrl, .1, interaction); if (st) { interaction.reply({ content: "Joined, trying to start playing.", ephemeral: true }); } else { interaction.reply({ content: `Failed to play stream`, ephemeral: true }); } break; case "nwrplay": // Play NWR stream if (!interaction.inGuild()) return interaction.reply({ content: "This command can only be used in a guild", ephemeral: true }); // Get the callsign const callsign = interaction.options.getString("callsign"); // Get the URL associated with the callsign url = nwrstreams.callsigns[callsign]; // Get the voice channel channel = interaction.member.voice?.channel; // Use a unique variable name if (!channel) return interaction.reply({ content: "You need to be in a voice channel", ephemeral: true }); // Join the channel and play the stream const streamStatus = JoinChannel(channel, url, .1, interaction); // Use a unique variable name if (streamStatus) { interaction.reply({ content: "Joined, trying to start playing.", ephemeral: true }); } else { interaction.reply({ content: `Failed to play stream`, ephemeral: true }); } break; case "leave": // Leave Channel if (!interaction.inGuild()) return interaction.reply({ content: "This command can only be used in a guild", ephemeral: true }); channel = interaction.member.voice.channel; if (!channel) return interaction.reply({ content: "You need to be in a voice channel", ephemeral: true }); res = LeaveVoiceChannel(channel) if (res) { interaction.reply({ content: "Left voice channel", ephemeral: true }); } else { interaction.reply({ content: "Failed to leave voice channel (Was i ever in one?)", ephemeral: true }); } break; case "pause": // Pause/unpause stream if (!interaction.inGuild()) return interaction.reply({ content: "This command can only be used in a guild", ephemeral: true }); channel = interaction.member.voice.channel; if (!channel) return interaction.reply({ content: "You need to be in a voice channel", ephemeral: true }); res = toggleVoicePause(channel) if (res) { interaction.reply({ content: "Toggled pause", ephemeral: true }); } else { interaction.reply({ content: "Failed to toggle pause", ephemeral: true }); } break; case "volume": // Set volume if (!interaction.inGuild()) return interaction.reply({ content: "This command can only be used in a guild", ephemeral: true }); channel = interaction.member.voice.channel; if (!channel) return interaction.reply({ content: "You need to be in a voice channel", ephemeral: true }); volume = interaction.options.getInteger("volume") / 100; // Make sure volume isnt negative if (volume < 0) volume = 0; if (volume > 1) volume = 1; res = setVolume(channel, volume) if (res) { interaction.reply({ content: `Set volume to ${volume * 100}%` }); } else { interaction.reply({ content: "Failed to set volume", ephemeral: true }); } break; case "outlook": day = interaction.options.getInteger("day"); type = interaction.options.getString("type"); if (day < 0 || day > 7) return interaction.reply({ content: "Invalid day", ephemeral: true }); if (type !== "fire" && type !== "convective") return interaction.reply({ content: "Invalid type", ephemeral: true }); url = outlookURLs[type][day]; await interaction.deferReply(); fetch(url).then((res) => { if (res.status !== 200) { interaction.editReply({ content: "Failed to get outlook", ephemeral: true }); return; } res.buffer().then(async (buffer) => { // Check all overlays and add them to image as selected using Jimp overlays = ["population", "city", "cwa", "rfc", "interstate", "county", "tribal", "artcc", "fema"] await Jimp.read(buffer).then((image) => { outImg = image; cnt = 0; sendMsg = setTimeout(() => { interaction.editReply({ embeds: [{ title: `${toTitleCase(type)} Outlook Day ${day + 1}`, image: { url: `attachment://${type}_${day}.png` }, color: 0x00ff00 }], files: [{ attachment: buffer, name: `${type}_${day}.png` }] }); }, 150) overlays.forEach((overlay) => { if (interaction.options.getBoolean(`${overlay}_overlay`)) { clearTimeout(sendMsg); Jimp.read(`./images/overlays/${overlay}.png`).then((overlayImage) => { outImg.composite(overlayImage, 0, 0); sendMsg = setTimeout(() => { outImg.getBufferAsync(Jimp.MIME_PNG).then((buffer) => { interaction.editReply({ embeds: [{ title: `${toTitleCase(type)} Outlook Day ${day + 1}`, image: { url: `attachment://${type}_${day}.png` }, color: 0x00ff00 }], files: [{ attachment: buffer, name: `${type}_${day}.png` }] }); }); }, 150) }); } }) // interaction.editReply({ // embeds: [{ // title: `${toTitleCase(type)} Outlook Day ${day + 1}`, // image: { // url: `attachment://${type}_${day}.png` // }, // color: 0x00ff00 // }], // files: [{ // attachment: buffer, // name: `${type}_${day}.png` // }] // }); }); }); }).catch((err) => { interaction.editReply({ content: "Failed to get outlook", ephemeral: true }); console.log(`${colors.red("[ERROR]")} Failed to get outlook: ${err.message}`); console.error(err); }); break; case "alertmap": const alertmapurl = "https://forecast.weather.gov/wwamap/png/US.png" await interaction.deferReply(); fetch(alertmapurl).then((res) => { if (res.status !== 200) { interaction.editReply({ content: "Failed to get alert map", ephemeral: true }); return; } res.buffer().then(async (buffer) => { interaction.editReply({ embeds: [{ title: `Alert Map`, image: { url: `attachment://alerts.png` }, color: 0x00ff00 }], files: [{ attachment: buffer, name: `alerts.png` }] }); }); }).catch((err) => { interaction.editReply({ content: "Failed to get alert map", ephemeral: true }); console.log(`${colors.red("[ERROR]")} Failed to get alert map: ${err.message}`); console.error(err); }); break; case "satellite": // Get satellite images sat = interaction.options.getString("satellite"); if (!satellites[sat]) return interaction.reply({ content: "Invalid satellite", ephemeral: true }); // Fetch all the images productOptions = [] await (() => { for (const key in satellites[sat].products) { // make a discord customid safe id for the product name, add it to the satellites object satellites[sat].products[key].customid = key.replace(/[^a-zA-Z0-9]/g, "").toLowerCase(); productOptions.push({ label: key, value: satellites[sat].products[key].customid }) } console.log(JSON.stringify(productOptions, null, 2)) })(); satMessages[interaction.id] = { sat } await interaction.reply({ content: "Choose a product", components: [ { type: 1, components: [ { type: 3, custom_id: `satproduct|${interaction.id}`, label: "Product", // map options to product names options: productOptions } ] } ] }) break; case "forecast": await interaction.deferReply(); periods = interaction.options.getInteger("periods") || 1; funcs.getWeatherBySearch(interaction.options.getString("location")).then((weather) => { if (config.debug >= 1) console.log(JSON.stringify(weather, null, 2)) embeds = funcs.generateDiscordEmbeds(weather, periods); interaction.editReply({ embeds }); }).catch((err) => { interaction.editReply({ content: "Failed to get forecast", ephemeral: true }); if (config.debug >= 1) console.log(`${colors.red("[ERROR]")} Failed to get forecast: ${err}`); }); break; } case Discord.InteractionType.MessageComponent: if (!interaction.customId) return; switch (interaction.customId.split("|")[0]) { case "product": if (interaction.customId) { const product_id = interaction.customId.split("|")[1]; url = `https://mesonet.agron.iastate.edu/api/1/nwstext/${product_id}`; await interaction.deferReply({ ephemeral: true }); fetch(url).then((res) => { if (res.status !== 200) { interaction.reply({ content: "Failed to get product text", ephemeral: true }); return; } // Retruns raw text, paginate it into multiple embeds if needed res.text().then(async (text) => { const pages = text.match(/[\s\S]{1,1900}(?=\n|$)/g); // const embeds = pages.map((page, ind) => ({ // title: `Product Text for ${product_id} Pg ${ind + 1}/${pages.length}`, // description: `\`\`\`${page}\`\`\``, // color: 0x00ff00 // })); const messages = pages.map((page, ind) => { return `\`\`\`${page}\`\`\`` }) messages.forEach(async (message) => { interaction.followUp({ content: message, ephemeral: true }); }) }); }).catch((err) => { interaction.reply({ content: "Failed to get product text", ephemeral: true }); console.log(`${colors.red("[ERROR]")} Failed to get product text: ${err.message}`); }); } break; case "satproduct": satData = satMessages[interaction.customId.split("|")[1]]; sat = satData.sat product = interaction.values[0]; // find the original product name product_name = Object.keys(satellites[sat].products).find(key => satellites[sat].products[key].customid === product); imageOptions = [] satMessages[interaction.customId.split("|")[1]] = { sat, product, product_name, images: {} } await (() => { // for key, value in satellites[sat].products[product_name] console.log(product_name) for (const key in satellites[sat].products[product_name]) { // make a discord customid safe id for the product name, add it to the satellites object //console.log(satellites[sat].products[product_name]) if (key === "customid") continue; satMessages[interaction.customId.split("|")[1]].images[key.replace(/[^a-zA-Z0-9]/g, "").toLowerCase()] = satellites[sat].products[product_name][key]; imageOptions.push({ label: key, value: key.replace(/[^a-zA-Z0-9]/g, "").toLowerCase() }) } })(); interaction.deferReply(); await interaction.message.edit({ content: "Choose an image to view", components: [ { type: 1, components: [ { type: 3, custom_id: `satproduct2|${interaction.customId.split("|")[1]}`, label: "Image", // map options to product names options: imageOptions } ] } ] }).then(() => { interaction.deleteReply(); }); break; case "satproduct2": satData = satMessages[interaction.customId.split("|")[1]]; sat = satData.sat; product = satData.product; product_name = satData.product_name; image = interaction.values[0]; url = satData.images[image]; // get filename from url filename = url.split("/").pop(); interaction.deferReply(); // Get the image fetch(url).then((res) => { if (res.status !== 200) { interaction.message.edit({ content: "Failed to get image", ephemeral: true }).then(() => { interaction.deleteReply(); }); return; } embeds = []; files = []; res.buffer().then(async (buffer) => { files.push({ attachment: buffer, name: filename }) embeds.push({ title: `${sat}/${product_name}/${image}`, image: { url: `attachment://${filename}` }, color: 0x00ff00 }); interaction.message.edit({ embeds, files, components: [], content: null }).then(() => { interaction.deleteReply(); }); } ); }).catch((err) => { interaction.message.edit({ content: "Failed to get image", ephemeral: true }).then(() => { interaction.deleteReply(); }); console.log(`${colors.red("[ERROR]")} Failed to get image: ${err.stack}`); }); break; } break; case Discord.InteractionType.ApplicationCommandAutocomplete: //map nwrstreams if (interaction.commandName === "nwrplay") { let callsignSearch = interaction.options.getString("callsign"); let callsigns = Object.keys(nwrstreams.callsigns); let results = callsigns.filter((callsign) => callsign.toLowerCase().includes(callsignSearch.toLowerCase())); if (results.length > 25) { results = results.slice(0, 25); } interaction.respond(results.map((callsign) => ({ name: callsign, value: callsign }))); } break; } }); discord.on("guildCreate", async (guild) => { let logs = await guild.fetchAuditLogs() logs = logs.entries.filter(e => e.action === Discord.AuditLogEvent.BotAdd) let user = logs.find(l => l.target?.id === discord.user.id)?.executor // Get the main guild const myGuild = discord.guilds.cache.get(config.discord.mainGuild); // Get the log channel const channel = myGuild.channels.cache.get(config.discord.logChannel); // Send a message to the log channel let invite = await discord.guilds.cache.get(config.discord.mainGuild).channels.cache.get(config.discord.inviteChannel).createInvite(); user.send({ embeds: [{ description: `Thanks for adding ${discord.user.username}!\nIf you have **ANY** questions, comments, suggestions, bug reports, etc, please feel free to throw it by us in our support server!\n\nTo get started use \`/subscribe\` to get alerts!`, color: 0x00ff00 }], components: [ { type: Discord.ComponentType.ActionRow, components: [ { type: Discord.ComponentType.Button, url: `https://discord.gg/${invite.code}`, style: Discord.ButtonStyle.Link, emoji: "ℹ", label: "IEM Alerter Support Server" } ] } ] }).catch((err) => { console.log(`${colors.red("[ERROR]")} Failed to send message to user ${user.id}: ${err.message}`); }) channel.send({ embeds: [ { description: `I joined \`${guild.name}\``, fields: [ { "name": "User", "value": `<@${user.id}> (@${user.username}) ${user.displayName}` } ], color: 0x00ff00 } ] }) }) discord.on("guildDelete", (guild) => { // Get the main guild const myGuild = discord.guilds.cache.get(config.discord.mainGuild); // Get the log channel const channel = myGuild.channels.cache.get(config.discord.logChannel); // Send a message to the log channel channel.send({ embeds: [ { description: `I left \`${guild.name}\``, color: 0xff0000 } ] }) }) process.on("unhandledRejection", (error, promise) => { console.log(`${colors.red("[ERROR]")} Unhandled Rejection @ ${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}\n\nPROMISE:\n${JSON.stringify(promise)}`); // Send discord log const myGuild = discord.guilds.cache.get(config.discord.mainGuild); const channel = myGuild.channels.cache.get(config.discord.logChannel); channel.send({ embeds: [ { description: `Unhandled Rejection\n\`\`\`${error}\n${JSON.stringify(promise)}\`\`\``, color: 0xff0000 } ] }) 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); // Send message to log channel const myGuild = discord.guilds.cache.get(config.discord.mainGuild); const channel = myGuild.channels.cache.get(config.discord.logChannel); channel.send({ embeds: [ { description: `Uncaught Exception\n\`\`\`${error.message}\n${error.stack}\`\`\``, color: 0xff0000 } ] }) return; }); // Login to discord discord.login(config.discord.token);