1702 lines
59 KiB
JavaScript
1702 lines
59 KiB
JavaScript
// 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");
|
||
const path = require("path");
|
||
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);`);
|
||
});
|
||
|
||
|
||
// 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.
|
||
|
||
*/
|
||
|
||
var sent = {}
|
||
|
||
const handleDiscord = function (data, randStr) {
|
||
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;
|
||
var evt = events[product_id.pil.substring(0, 3)];
|
||
evt.code = product_id.pil.substring(0, 3);
|
||
console.log(`${colors.cyan("[INFO]")} ${fromChannel} @ ${product_id.timestamp}; ${evt.code} (${evt.priority}) ${body.string}`);
|
||
if (evt.disable) return console.log(`${colors.yellow("[WARN]")} Event ${evt.code} is disabled, skipping...`);
|
||
let embed = {
|
||
description: `<t:${new Date(product_id.timestamp) / 1000}:T> <t:${new Date(product_id.timestamp) / 1000}:R> ${body.string}`,
|
||
color: parseInt(config.priorityColors[evt.priority].replace("#", ""), 16) || 0x000000,
|
||
timestamp: product_id.timestamp,
|
||
footer: {
|
||
text: `${randStr} 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) => {
|
||
// 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) => body.string.toLowerCase().includes(filter)) && !filters.some((filter) => text.toLowerCase().includes(filter))) return;
|
||
thisMsg = JSON.parse(JSON.stringify(discordMsg));
|
||
thisMsg.content = row.custommessage || null;
|
||
// Debounce by channel id and pil to prevent duplicate alerts
|
||
if (!sent[row.channelid]) sent[row.channelid] = {};
|
||
if (sent[row.channelid][product_id_raw]) {
|
||
// Already sent this pil to this channel recently, skip
|
||
console.log(`${colors.yellow("[WARN]")} Already sent ${product_id_raw} to ${channel.guild.name}/${channel.name} (${channel.guild.id}/${channel.id}), skipping...`);
|
||
return;
|
||
}
|
||
sent[row.channelid][product_id_raw] = Date.now();
|
||
// Optionally, clean up old entries after some time (e.g., 10 minutes)
|
||
setTimeout(() => {
|
||
if (sent[row.channelid]) {
|
||
delete sent[row.channelid][product_id_raw];
|
||
if (Object.keys(sent[row.channelid]).length === 0) {
|
||
delete sent[row.channelid];
|
||
}
|
||
}
|
||
}, 10 * 60 * 1000);
|
||
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`);
|
||
|
||
// Debounce by user id and pil to prevent duplicate alerts
|
||
if (!sent[row.userid]) sent[row.userid] = {};
|
||
if (sent[row.userid][product_id_raw]) {
|
||
// Already sent this pil to this user recently, skip
|
||
console.log(`${colors.yellow("[WARN]")} Already sent ${product_id_raw} to ${user.tag} (${user.id}), skipping...`);
|
||
return;
|
||
}
|
||
sent[row.userid][product_id_raw] = Date.now();
|
||
// Optionally, clean up old entries after some time (e.g., 10 minutes)
|
||
setTimeout(() => {
|
||
if (sent[row.userid]) {
|
||
delete sent[row.userid][product_id_raw];
|
||
if (Object.keys(sent[row.userid]).length === 0) {
|
||
delete sent[row.userid];
|
||
}
|
||
}
|
||
}, 10 * 60 * 1000);
|
||
|
||
|
||
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.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
|
||
}
|
||
]
|
||
})
|
||
db.run(`DELETE FROM userAlerts WHERE userid = ?`, [row.userid], (err) => {
|
||
if (err) {
|
||
console.error(err.message);
|
||
}
|
||
console.log(`${colors.cyan("[INFO]")} Deleted all subs for 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",
|
||
{
|
||
headers: {
|
||
"user-agent": "IEM-Alerter-DiscordBot/1.0 (+https://git.chrischro.me/iem-alerter/discord-bot; contact: me@ko4wal.radio)"
|
||
}
|
||
}
|
||
);
|
||
|
||
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) => {
|
||
// Save copy of rawData for debugging to event-logs/year/month/dat/random6charstring-unixtimestamp.json
|
||
const timestamp = new Date().toISOString().replace(/:/g, '-');
|
||
const randomString = generateRandomString({ lower: true, upper: true, number: true }, 6);
|
||
const logDir = path.join(__dirname, "event-logs", timestamp.substring(0, 4), timestamp.substring(5, 7), timestamp.substring(8, 10));
|
||
if (!fs.existsSync(logDir)) {
|
||
fs.mkdirSync(logDir, { recursive: true });
|
||
}
|
||
const logFile = path.join(logDir, `${randomString}-${Date.now()}.json`);
|
||
fs.writeFileSync(logFile, rawData);
|
||
|
||
try {
|
||
const data = JSON.parse(rawData);
|
||
|
||
switch (data.type) {
|
||
case "iem-message":
|
||
handleDiscord(data, randomString);
|
||
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;
|
||
}
|
||
interaction.deferReply({ ephemeral: true });
|
||
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.editReply({ content: "Failed to unsubscribe from room", ephemeral: true });
|
||
}
|
||
if (!row) {
|
||
return interaction.editReply({ 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.editReply({ content: "Failed to unsubscribe from room", ephemeral: true });
|
||
}
|
||
interaction.editReply({ 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.editReply({ content: "Failed to unsubscribe from room", ephemeral: true });
|
||
}
|
||
if (!row) {
|
||
return interaction.editReply({ 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.editReply({ content: "Failed to unsubscribe from room", ephemeral: true });
|
||
}
|
||
interaction.editReply({ 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 <t:${Math.floor(startTimestap / 1000)}>, Started <t:${Math.floor(startTimestap / 1000)}:R>`,
|
||
},
|
||
{
|
||
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 "bugreport":
|
||
isGuild = interaction.inGuild();
|
||
bugReportChannel = await discord.channels.fetch(config.discord.bugReportChannel);
|
||
report = {
|
||
content: config.discord.bugReportChannelMessage || null,
|
||
embeds: [{
|
||
title: "Bug Report",
|
||
author: {
|
||
name: isGuild ? `${interaction.user.username} in ${interaction.guild.name}` : interaction.user.username,
|
||
icon_url: isGuild ? interaction.guild.iconURL() : interaction.user.displayAvatarURL()
|
||
},
|
||
description: interaction.options.getString("description"),
|
||
color: 0xff0000,
|
||
timestamp: new Date(),
|
||
footer: {
|
||
text: "Bug Report",
|
||
icon_url: interaction.user.displayAvatarURL()
|
||
},
|
||
image: {
|
||
url: interaction.options.getAttachment("screenshot") ? interaction.options.getAttachment("screenshot").url : null
|
||
},
|
||
fields: [
|
||
{
|
||
name: "User",
|
||
value: `${interaction.user.username} (${interaction.user.id})`,
|
||
inline: true
|
||
},
|
||
{
|
||
name: "Guild",
|
||
value: isGuild ? interaction.guild.name : "DM",
|
||
inline: true
|
||
},
|
||
{
|
||
name: "Channel",
|
||
value: interaction.options.getChannel("channel") ? interaction.options.getChannel("channel").toString() : "N/A",
|
||
inline: true
|
||
},
|
||
{
|
||
name: "Reproduction Steps",
|
||
value: interaction.options.getString("reproduction_steps") || "N/A",
|
||
inline: false
|
||
},
|
||
{
|
||
name: "Expected Result",
|
||
value: interaction.options.getString("expected_result") || "N/A",
|
||
inline: false
|
||
},
|
||
{
|
||
name: "Actual Result",
|
||
value: interaction.options.getString("actual_result") || "N/A",
|
||
inline: false
|
||
}
|
||
],
|
||
}]
|
||
}
|
||
bugReportChannel.send(report).then(() => {
|
||
interaction.reply({ content: "Bug report sent! Thank you for your feedback.", embeds: report.embeds});
|
||
}).catch((err) => {
|
||
interaction.reply({ content: "Failed to send bug report. Please try again later.", ephemeral: true });
|
||
console.error(`${colors.red("[ERROR]")} Failed to send bug report: ${err.message}`);
|
||
});
|
||
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); |