weather-bot/shard.js
2024-07-06 12:03:54 -06:00

572 lines
18 KiB
JavaScript

const config = require("./config.json");
const {
REST,
Routes
} = require('discord.js');
const rest = new REST({
version: '10'
}).setToken(config.discord.token);
const sqlite3 = require("sqlite3").verbose();
// Setup SQlite DB
const db = new sqlite3.Database("channels.db", (err) => {
if (err) {
console.log(`${colors.red("[ERROR]")} Error connecting to database: ${err.message}`);
}
console.log(`${colors.cyan("[INFO]")} Connected to the database`);
// Create tables if they dont exist
db.run(`CREATE TABLE IF NOT EXISTS channels (channelid TEXT, iemchannel TEXT, custommessage TEXT, minPriority INTEGER, "filter" TEXT, filterevt TEXT);`);
db.run(`CREATE TABLE IF NOT EXISTS userAlerts (userid TEXT, iemchannel TEXT, filter TEXT, filterEvt TEXT, minPriority INT, custommessage TEXT);`);
});
// Requires
const fs = require("fs");
const funcs = require("./funcs.js");
const wfos = require("./data/wfos.json");
const blacklist = require("./data/blacklist.json");
const events = require("./data/events.json");
const outlookURLs = require("./data/outlook.json");
const sattelites = require("./data/sattelites.json");
const nwrstreams = require("./data/nwrstreams.json")
const Jimp = require("jimp");
const { client, xml } = require("@xmpp/client");
const fetch = require("node-fetch");
const html = require("html-entities")
const Discord = require("discord.js");
const dVC = require("@discordjs/voice");
const colors = require("colors");
const parseProductID = function (product_id) {
const [timestamp, station, wmo, pil] = product_id.split("-");
return {
timestamp: convertDate(timestamp),
originalTimestamp: timestamp,
station,
wmo,
pil
};
}
// Convert date format 202405080131 (YYYYMMddHHmm) to iso format, hours and mins is UTC
const convertDate = function (date) {
const year = date.substring(0, 4);
const month = date.substring(4, 6);
const day = date.substring(6, 8);
const hours = date.substring(8, 10);
const mins = date.substring(10, 12);
// Because they don't have seconds, assume current seconds
const secs = new Date().getSeconds();
return new Date(Date.UTC(year, month - 1, day, hours, mins, secs));
}
// Get first url in a string, return object {string, url} remove the url from the string
const getFirstURL = function (string) {
url = string.match(/(https?:\/\/[^\s]+)/g);
if (!url) return { string, url: null };
const newString = string.replace(url[0], "");
return { string: newString, url: url[0] };
}
// Get WFO data from room name
function getWFOByRoom(room) {
room = room.toLowerCase();
for (const key in wfos) {
if (wfos.hasOwnProperty(key) && wfos[key].room === room) {
return wfos[key];
}
}
return {
location: room,
room: room
};
}
// Voice funcs
function JoinChannel(channel, track, volume, message) {
connection = dVC.joinVoiceChannel({
channelId: channel.id,
guildId: channel.guild.id,
adapterCreator: channel.guild.voiceAdapterCreator,
selfDeaf: true
});
resource = dVC.createAudioResource(track, { inlineVolume: true, silencePaddingFrames: 5 });
player = dVC.createAudioPlayer();
connection.player = player; // So we can access it later to pause/play/stop etc
resource.volume.setVolume(volume);
connection.subscribe(player)
player.play(resource);
connection.on(dVC.VoiceConnectionStatus.Ready, () => { player.play(resource); })
connection.on(dVC.VoiceConnectionStatus.Disconnected, async (oldState, newState) => {
try {
await Promise.race([
dVC.entersState(connection, VoiceConnectionStatus.Signalling, 5_000),
dVC.entersState(connection, VoiceConnectionStatus.Connecting, 5_000),
]);
} catch (error) {
message.channel.send(`Failed to reconnect to the voice channel. Stopping for now.`);
connection.destroy();
return false;
}
});
player.on('error', error => {
console.error(`Error: ${error.message} with resource ${error.resource.metadata.title}`);
message.channel.send(`Error while streaming. Stopping for now.`);
player.stop();
});
player.on(dVC.AudioPlayerStatus.Playing, () => {
message.channel.send(`Playing stream in <#${channel.id}>`);
connection.paused = false;
});
player.on('idle', () => {
message.channel.send(`Stream idle.`);
})
return true;
}
function LeaveVoiceChannel(channel) {
// Get resource, player, etc, and destroy them
const connection = dVC.getVoiceConnection(channel.guild.id);
if (connection) {
connection.destroy();
return true
}
return false
}
function toggleVoicePause(channel) {
const connection = dVC.getVoiceConnection(channel.guild.id);
if (connection) {
if (connection.paused) {
connection.player.unpause();
connection.paused = false;
return true;
}
else {
connection.player.pause();
connection.paused = true;
return true
}
}
else {
return false;
}
};
function setVolume(channel, volume) {
const connection = dVC.getVoiceConnection(channel.guild.id);
if (connection) {
connection.player.state.resource.volume.setVolume(volume);
return true;
}
}
// func to Generate random string, ({upper, lower, number, special}, length)
const generateRandomString = function (options, length) {
let result = '';
const characters = {
upper: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ',
lower: 'abcdefghijklmnopqrstuvwxyz',
number: '0123456789',
special: '!@#$%^&*()_+'
};
let chars = '';
for (const key in options) {
if (options[key]) {
chars += characters[key];
}
}
for (let i = 0; i < length; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length));
}
return result;
}
// Func to generate UUID
const generateUUID = function () {
return generateRandomString({ lower: true, upper: true, number: true }, 8) + "-" + generateRandomString({ lower: true, upper: true, number: true }, 4) + "-" + generateRandomString({ lower: true, upper: true, number: true }, 4) + "-" + generateRandomString({ lower: true, upper: true, number: true }, 4) + "-" + generateRandomString({ lower: true, upper: true, number: true }, 12);
}
// Variable setup
var iem = []
var startup = true;
var messages = 0;
var errCount = 0;
const curUUID = generateUUID();
const xmpp = client({
service: "xmpp://conference.weather.im",
domain: "weather.im",
resource: `discord-weather-bot-${generateRandomString({ upper: true, lower: true, number: true }, 5)}`, // Weird fix to "Username already in use"
});
//debug(xmpp, true);
xmpp.on("error", (err) => {
console.log(`${colors.red("[ERROR]")} XMPP Error: ${err}. Trying to reconnect...`);
setTimeout(() => {
xmpp.stop().then(() => {
start();
});
}, 5000);
});
xmpp.on("offline", () => {
console.log(`${colors.yellow("[WARN]")} XMPP offline, trying to reconnect...`);
xmpp.disconnect().then(() => {
xmpp.stop().then(() => {
start();
})
})
});
xmpp.on("stanza", (stanza) => {
// Debug stuff
if (config.debug >= 2) console.log(`${colors.magenta("[DEBUG]")} Stanza: ${stanza.toString()}`);
// Handle Room List
if (stanza.is("iq") && stanza.attrs.type === "result" && stanza.getChild("query")) {
query = stanza.getChild("query");
if (query.attrs.xmlns === "http://jabber.org/protocol/disco#items") {
query.getChildren("item").forEach((item) => {
// Check if the JID is on the blacklist, if so, ignore it
if (blacklist.includes(item.attrs.jid)) return;
// get proper name from wfos
const wfo = getWFOByRoom(item.attrs.jid.split("@")[0]);
item.attrs.properName = wfo.location;
iem.push(item.attrs);
console.log(`${colors.cyan("[INFO]")} Found room: ${item.attrs.jid}`);
// Join the room
//xmpp.send(xml("presence", { to: `${channel.jid}/${channel.name}/${curUUID}` }, xml("item", { role: "visitor" })));
xmpp.send(xml("presence", { to: `${item.attrs.jid}/${curUUID}` }, xml("item", { role: "visitor" })));
});
}
}
// Get new messages and log them, ignore old messages
if (stanza.is("message") && stanza.attrs.type === "groupchat") {
// Stops spam from getting old messages
if (startup) return;
// Get channel name
fromChannel = stanza.attrs.from.split("@")[0];
// Ignores
if (!stanza.getChild("x")) return; // No PID, ignore it
if (!stanza.getChild("x").attrs.product_id) return;
const product_id = parseProductID(stanza.getChild("x").attrs.product_id);
const product_id_raw = stanza.getChild("x").attrs.product_id;
// Get body of message
const body = html.decode(stanza.getChildText("body"));
const bodyData = getFirstURL(body);
// get product id from "x" tag
var evt = events[product_id.pil.substring(0, 3)];
if (!evt) {
evt = { name: "Unknown", priority: 3 }
console.log(`${colors.red("[ERROR]")} Unknown event type: ${product_id.pil.substring(0, 3)}. Fix me`);
console.log(`${colors.magenta("[DEBUG]")} ${bodyData.string}`)
}
evt.code = product_id.pil.substring(0, 3);
// Check timestamp, if not within 3 minutes, ignore it
const now = new Date();
const diff = (now - product_id.timestamp) / 1000 / 60;
if (diff > 3) return;
if (config.debug >= 1) console.log(`${colors.magenta("[DEBUG]")} New message from ${fromChannel}`);
messages++;
// Handle NTFY
if (config.ntfy.enabled) {
if (config.debug >= 1) console.log(`${colors.magenta("[DEBUG]")} Sending NTFY for ${config.ntfy.prefix}${fromChannel}`)
ntfyBody = {
"topic": `${config.ntfy.prefix}${fromChannel}`,
"message": bodyData.string,
"tags": [`Timestamp: ${product_id.timestamp}`, `Station: ${product_id.station}`, `WMO: ${product_id.wmo}`, `PIL: ${product_id.pil}`, `Channel: ${fromChannel}`],
"priority": evt.priority,
"actions": [{ "action": "view", "label": "Product", "url": bodyData.url }, { "action": "view", "label": "Product Text", "url": `https://mesonet.agron.iastate.edu/api/1/nwstext/${product_id_raw}` }]
}
if (stanza.getChild("x").attrs.twitter_media) {
ntfyBody.attach = stanza.getChild("x").attrs.twitter_media;
}
fetch(config.ntfy.server, {
method: 'POST',
body: JSON.stringify(ntfyBody),
headers: {
'Authorization': `Bearer ${config.ntfy.token}`
}
}).then((res) => {
if (config.debug >= 1) console.log(`${colors.magenta("[DEBUG]")} NTFY sent for ${config.ntfy.prefix}${fromChannel} with status ${res.status} ${res.statusText}`);
if (res.status !== 200) console.log(`${colors.red("[ERROR]")} NTFY failed for ${config.ntfy.prefix}${fromChannel} with status ${res.status} ${res.statusText}`);
}).catch((err) => {
console.error(err)
})
}
// Send discord msg
let embed = {
description: `<t:${product_id.timestamp / 1000}:T> <t:${product_id.timestamp / 1000}:R> ${bodyData.string}`,
color: parseInt(config.priorityColors[evt.priority].replace("#", ""), 16) || 0x000000,
timestamp: product_id.timestamp,
footer: {
text: `Station: ${product_id.station} PID: ${product_id_raw} Channel: ${fromChannel}`
}
}
if (stanza.getChild("x").attrs.twitter_media) {
embed.image = {
url: stanza.getChild("x").attrs.twitter_media
}
}
let discordMsg = {
embeds: [embed],
components: [
{
type: 1,
components: [
{
type: 2,
label: "Product",
style: 5,
url: bodyData.url
},
{
type: 2,
style: 1,
custom_id: product_id_raw,
label: "Product Text",
emoji: {
name: "📄"
}
}
]
}
]
}
// Discord Channel Handling
db.all(`SELECT * FROM channels WHERE iemchannel = ?`, [fromChannel], (err, rows) => {
if (err) {
console.log(`${colors.red("[ERROR]")} ${err.message}`);
}
if (!rows) return; // No channels to alert
rows.forEach((row) => {
// Get Filters as arrays
if (!row.filterEvt) row.filterEvt = "";
if (!row.filter) row.filter = "";
let filterEvt = row.filterEvt.toLowerCase().split(",");
let filters = row.filter.toLowerCase().split(",");
if (evt.priority < row.minPriority) return;
// If the event type is not in th filter, ignore it. Make sure filterEvt isnt null
if (!filterEvt[0]) filterEvt = [];
if (!filterEvt.includes(evt.code.toLowerCase()) && !filterEvt.length == 0) return;
// fetch the product text
trySend = () => {
fetch(`https://mesonet.agron.iastate.edu/api/1/nwstext/${product_id_raw}`).then((res) => {
// If neither the body nor the product text contains the filter, ignore it
res.text().then((text) => {
if (!filters.some((filter) => body.toLowerCase().includes(filter)) && !filters.some((filter) => text.toLowerCase().includes(filter))) return;
thisMsg = JSON.parse(JSON.stringify(discordMsg));
thisMsg.content = row.custommessage || null;
// set channelid var in shard
channelid = row.channelid;
manager.broadcast({
type: "sendMsgChannel",
channel: channelid,
msg: thisMsg
})
});
}).catch((err) => {
setTimeout(() => {
console.log(`${colors.red("[ERROR]")} Failed to fetch product text, retrying... ${err}`)
trySend();
})
});
}
trySend();
});
});
// User DM alert handling
db.all(`SELECT * FROM userAlerts WHERE iemchannel = ?`, [fromChannel], (err, rows) => {
if (err) {
console.error(err.message);
}
if (!rows) return; // No users to alert
rows.forEach((row) => {
// Get Filters as arrays
if (!row.filterEvt) row.filterEvt = "";
if (!row.filter) row.filter = "";
let filterEvt = row.filterEvt.toLowerCase().split(",");
let filters = row.filter.toLowerCase().split(",");
// If priority is less than the min priority, ignore it
if (evt.priority < row.minPriority) return;
// If the event type is not in th filter, ignore it. Make sure filterEvt isnt null
if (!filterEvt[0]) filterEvt = [];
if (!filterEvt.includes(evt.code.toLowerCase()) && !filterEvt.length == 0) return;
// fetch the product text
trySend = () => {
fetch(`https://mesonet.agron.iastate.edu/api/1/nwstext/${product_id_raw}`).then((res) => {
// If neither the body nor the product text contains the filter, ignore it
res.text().then((text) => {
if (!filters.some((filter) => body.toLowerCase().includes(filter)) && !filters.some((filter) => text.toLowerCase().includes(filter))) return;
thisMsg = JSON.parse(JSON.stringify(discordMsg));
thisMsg.content = row.custommessage || null;
// set channelid var in shard
userid = row.userid;
manager.broadcast({
type: "sendMsgUser",
user: userid,
msg: thisMsg
})
});
}).catch((err) => {
setTimeout(() => {
console.log(`${colors.red("[ERROR]")} Failed to fetch product text, retrying... ${err}`)
trySend();
})
});;
}
trySend();
});
});
}
});
xmpp.on("online", async (address) => {
if (config["uptime-kuma"].enabled) {
fetch(config["uptime-kuma"].url).then(() => {
console.log(`${colors.cyan("[INFO]")} Sent heartbeat to Uptime Kuma`)
})
setInterval(() => {
// Send POST request to config["uptime-kuma"].url
fetch(config["uptime-kuma"].url).then(() => {
console.log(`${colors.cyan("[INFO]")} Sent heartbeat to Uptime Kuma`)
})
}, config["uptime-kuma"].interval * 1000) // Every X seconds
}
errCount = 0;
// Start listening on all channels, (dont ban me funny man)
// for (const channel in iem) {
// console.log(`Joining ${channel.name}`)
// await xmpp.send(xml("presence", { to: `${channel.jud}/${channel.name}` }));
// }
/* sub format
<presence to="botstalk@conference.weather.im/add9b8f1-038d-47ed-b708-6ed60075a82f" xmlns="jabber:client">
<x xmlns="http://jabber.org/protocol/muc#user">
<item>
<role>visitor</role>
</item>
</x>
</presence>
*/
// Request room list
// Automatically find room list
xmpp.send(xml("iq", { type: "get", to: "conference.weather.im", id: "rooms" }, xml("query", { xmlns: "http://jabber.org/protocol/disco#items" })));
// Join all channels (Old method)
// iem.forEach((channel => {
// console.log(`${colors.cyan("[INFO]")} Joining ${channel.jid}/${channel.name}/${curUUID}`)
// //xmpp.send(xml("presence", { to: `${channel.jid}/${channel.jid.split("@")[0]}` }));
// xmpp.send(xml("presence", { to: `${channel.jid}/${channel.name}/${curUUID}` }, xml("item", { role: "visitor" })));
// }))
console.log(`${colors.cyan("[INFO]")} Connected to XMPP server as ${address.toString()}`);
setTimeout(() => {
startup = false;
console.log(`${colors.cyan("[INFO]")} Startup complete, listening for messages...`);
}, 1000)
});
xmpp.on("close", () => {
console.log(`${colors.yellow("[WARN]")} XMPP connection closed, trying to reconnect...`);
xmpp.disconnect().then(() => {
xmpp.stop().then(() => {
start();
})
})
})
const start = () => {
startup = true;
xmpp.start().catch((err) => {
errCount++;
if (errCount >= 5) {
console.log(`${colors.red("[ERROR]")} XMPP failed to start after 5 attempts, exiting...`);
process.exit(1);
}
console.log(`${colors.red("[ERROR]")} XMPP failed to start: ${err}.`);
xmpp.disconnect().then(() => {
xmpp.stop().then(() => {
start();
})
})
});
}
// END XMPP
process.on("unhandledRejection", (error, promise) => {
console.log(`${colors.red("[ERROR]")} Unhandled Rejection @ ${JSON.stringify(promise)}: ${error.stack}`);
// create errors folder if it doesnt exist
if (!fs.existsSync("./error")) {
fs.mkdirSync("./error");
}
// write ./error/rejection_timestamp.txt
fs.writeFileSync(`./error/rejection_${Date.now()}.txt`, `ERROR:\n${error.stack}\n\nPROMISE:\n${JSON.stringify(promise)}`);
return;
});
process.on("uncaughtException", (error) => {
console.log(`${colors.red("[ERROR]")} Uncaught Exception: ${error.message}\n${error.stack}`);
if (!fs.existsSync("./error")) {
fs.mkdirSync("./error");
}
// write ./error/exception_timestamp.txt
fs.writeFileSync(`./error/exception_${Date.now()}.txt`, error.stack);
return;
});
const { ShardingManager } = require('discord.js');
const { ClusterManager } = require('discord-hybrid-sharding');
const manager = new ClusterManager(`${__dirname}/index.js`, {
totalShards: 2, // or numeric shard count
/// Check below for more options
shardsPerClusters: 1, // 2 shards per process
totalClusters: 1,
mode: 'process', // you can also choose "worker"
token: config.discord.token,
});
manager.on('clusterCreate', (cluster) => {
setInterval(() => {cluster.send({ type: "yourId", data: cluster.id})}, 1000);
})
manager.spawn({timeout: -1}).then(() => {
start();
console.log(`${colors.cyan("[INFO]")} Spawned all shards`);
setInterval(() => {
manager.broadcast({ type: "heartbeat" });
manager.broadcast({ type: "updateRooms", rooms: iem})
}, 1000)
});