Inital move (minus WIP tests)

This commit is contained in:
Logan Cusano
2024-05-12 12:55:00 -04:00
parent 132d974b89
commit 580513997d
21 changed files with 4687 additions and 0 deletions

77
modules/baseUtils.mjs Normal file
View File

@@ -0,0 +1,77 @@
import { networkInterfaces } from 'os';
import { createHash, randomBytes } from 'crypto';
import { promises, constants } from 'fs';
import { dirname } from "path";
/**
* Check to see if the input is a valid JSON string
*
* @param {*} str The string to check for valud JSON
* @returns {true|false}
*/
export const isJsonString = (str) => {
try {
JSON.parse(str);
} catch (e) {
return false;
}
return true;
}
/**
* Check to see if a path exists, creating it if it does not
* @returns {undefined}
*/
export const ensureDirectoryExists = async (filePath) => {
const directory = dirname(filePath);
try {
await promises.access(directory, constants.F_OK);
} catch (error) {
await promises.mkdir(directory, { recursive: true });
}
};
/**
* Generate a unique ID for a given device, this will also be unique on the same device at a different time.
* @returns {string} A generated unique ID
*/
export const generateUniqueID = () => {
const netInterfaces = networkInterfaces();
let macAddress = '';
// Find the first non-internal MAC address
for (const key in netInterfaces) {
const iface = netInterfaces[key][0];
if (!iface.internal) {
macAddress = iface.mac;
break;
}
}
// If no non-internal MAC address is found, fallback to a random value
if (!macAddress) {
macAddress = randomBytes(6).toString('hex').toUpperCase();
}
// Use MAC address and current timestamp to create a unique ID
const timestamp = Date.now();
const uniqueID = createHash('sha256')
.update(macAddress + timestamp)
.digest('hex');
return uniqueID;
}
/**
* Extracts the value after a specific pattern from a string using regular expressions.
* @param {string} input - The input string.
* @param {string} pattern - The pattern to match.
* @returns {string|null} The value found after the pattern, or null if not found.
*/
export const extractValue = (input, pattern) => {
const regex = new RegExp(`${pattern}`);
const match = input.match(regex);
return match ? match : null;
};

39
modules/cliHandler.mjs Normal file
View File

@@ -0,0 +1,39 @@
import { spawn } from "child_process";
/**
* Executes a command and retrieves its output.
* @param {string} command - The command to execute.
* @param {string[]} args - The arguments to pass to the command.
* @returns {Promise<string>} A promise that resolves with the output of the command.
*/
export const executeCommand = (command, args) => {
return new Promise((resolve, reject) => {
const childProcess = spawn(command, args);
let commandOutput = '';
childProcess.stdout.on('data', (data) => {
commandOutput += data.toString();
});
childProcess.stderr.on('data', (data) => {
// Log any errors to stderr
console.error(data.toString());
});
childProcess.on('error', (error) => {
// Reject the promise if there's an error executing the command
reject(error);
});
childProcess.on('close', (code) => {
if (code === 0) {
// Resolve the promise with the command output if it exits successfully
resolve(commandOutput.trim());
} else {
// Reject the promise if the command exits with a non-zero code
reject(new Error(`Command '${command}' exited with code ${code}`));
}
});
});
};

View File

@@ -0,0 +1,52 @@
import { getAllPresets } from "./radioPresetHandler.mjs";
import dotenv from 'dotenv';
dotenv.config()
/**
*
*/
export class ClientNodeObject {
/**
*
* @param {string} param0._nuid The ID of the node (Assigned by the client on first boot)
* @param {string} param0._name The name of the node (Assigned by the user)
* @param {string} param0._location The physical location of the node (Assigned by the user)
* @param {object} param0._capabilities The capabilities of this node (Assigned by the user, and determined by the hardware)
*/
constructor({ _nuid = undefined, _name = undefined, _location = undefined, _capabilities = undefined }) {
this.nuid = _nuid;
this.name = _name;
this.location = _location;
this.capabilities = _capabilities
}
}
/** The configuration object for the node */
export class ClientNodeConfig {
/**
*
* @param {string} param0._nuid The ID of the node (Assigned by the client on first boot)
* @param {string} param0._name The name of the node (Assigned by the user)
* @param {string} param0._location The physical location of the node (Assigned by the user)
* @param {object} param0._nearbySystems An object array of nearby systems (Assigned by the user)
* @param {object} param0._capabilities The capabilities of this node (Assigned by the user, and determined by the hardware)
*/
constructor({
_nuid = process.env.CLIENT_NUID,
_name = process.env.CLIENT_NAME,
_location = process.env.CLIENT_LOCATION,
_nearbySystems = getAllPresets(),
_capabilities = process.env.CLIENT_CAPABILITIES.split(", "),
_serverIp = process.env.SERVER_IP,
_serverPort = process.env.SERVER_PORT,
}) {
this.node = new ClientNodeObject({
_nuid: _nuid, _name: _name, _location: _location, _capabilities: _capabilities
});
this.nearbySystems = _nearbySystems;
this.serverIp = _serverIp;
this.serverPort = _serverPort;
}
}

View File

@@ -0,0 +1,144 @@
// Modules
import { writeFile, existsSync, readFileSync } from 'fs';
import { resolve } from "path";
import { ensureDirectoryExists } from "./baseUtils.mjs";
import convert_units from "convert-units";
const { converter } = convert_units;
import dotenv from 'dotenv';
dotenv.config()
const configFilePath = process.env.CONFIG_PATH;
/**
* Write the given presets to the JSON file
* @param presets The preset or presets to be written
* @param {function} callback The function to be called when this wrapper completes
*/
const writePresets = async (presets, callback = undefined) => {
console.log(`${__dirname}`);
await ensureDirectoryExists(configFilePath);
writeFile(configFilePath, JSON.stringify(presets), (err) => {
// Error checking
if (err) throw err;
console.log("Write Complete");
if (callback) callback(); else return
});
}
/**
* Wrapper to ensure each value in the array is in Hz format
* @param frequenciesArray
* @returns {*[]}
*/
const sanitizeFrequencies = async (frequenciesArray) => {
let sanitizedFrequencyArray = [];
for (const freq of frequenciesArray) {
sanitizedFrequencyArray.push(convertFrequencyToHertz(freq));
}
console.log("Sanitized Frequency Array", sanitizedFrequencyArray);
return sanitizedFrequencyArray;
}
/**
* Function to convert a string or a float into the integer type needed to be saved
* @param frequency Could be a string, number or float,
* @returns {number|number|*} Return the value to be saved in Hz format ("154.875"MHz format = "154875000")
*/
const convertFrequencyToHertz = async (frequency) => {
// check if the passed value is a number
if (typeof frequency == 'number' && !isNaN(frequency)) {
if (Number.isInteger(frequency)) {
console.log(`${frequency} is an integer.`);
// Check to see if the frequency has the correct length
if (frequency >= 1000000) return frequency
if (frequency >= 100 && frequency <= 999) return frequency * 1000000
console.log("Frequency hasn't matched filters: ", frequency);
}
else {
console.log(`${frequency} is a float value.`);
// Convert to a string to remove the decimal in place and then correct the length
return parseInt(converter(frequency).from("MHz").to("Hz"));
}
} else {
console.log(`${frequency} is not a number`);
frequency = convertFrequencyToHertz(parseFloat(frequency));
return parseInt(frequency)
}
}
/**
* Gets the saved presets and returns a preset object
* @returns {any} The object containing the different systems the bot is near
*/
export const getAllPresets = () => {
const presetDir = resolve(configFilePath);
console.log(`Getting presets from directory: '${presetDir}'`);
if (existsSync(presetDir)) return JSON.parse(readFileSync(presetDir));
else return {};
}
/**
* Adds a new preset to the radioPresets JSON file
*
* @param {string} systemName The name of the system being added
* @param {Array} frequencies The frequency or frequencies the SDR should tune to for this system
* @param {string} mode The listening mode the SDR should be using when listening to this frequency
* @param {function} callback The callback function to call when completed
* @param {string} trunkFile The file that contains all trunking information (if applicable to the selected listening mode)
* @param {string} whitelistFile The file that contains the whitelisted talkgroups [optional]
*/
export const addNewPreset = (systemName, frequencies, mode, callback, trunkFile = undefined, whitelistFile = undefined) => {
const presets = this.getPresets();
// Create the preset for the new system
presets[systemName] = {
"frequencies": sanitizeFrequencies(frequencies),
"mode": mode,
"trunkFile": trunkFile ?? "none",
"whitelistFile": whitelistFile ?? "none"
}
// Write the changes to the preset config file
writePresets(presets, callback);
}
/**
* Updates the specified system
*
* @param {string} systemName The name of the system being modified
* @param {function} callback The callback function to be called when the function completes
* @param {Array} frequencies The frequency or frequencies the SDR should tune to for this system
* @param {string} mode The listening mode the SDR should be using when listening to this frequency
* @param {string} trunkFile The file that contains all trunking information (if applicable to the selected listening mode)
* @param {string} whitelistFile The file that contains the whitelisted talkgroups [optional]
*/
export const updatePreset = (systemName, callback, { frequencies = undefined, mode = undefined, trunkFile = undefined, whitelistFile = undefined }) => {
const presets = this.getPresets();
// Check if a system name was passed
if (systemName in presets) {
// System name exists, checking to see if the keys are different
if (frequencies && sanitizeFrequencies(frequencies) !== presets[systemName].frequencies) presets[systemName].frequencies = sanitizeFrequencies(frequencies);
if (mode && mode !== presets[systemName].mode) presets[systemName].mode = mode;
if (trunkFile && trunkFile !== presets[systemName].trunkFile || trunkFile === "") presets[systemName].trunkFile = trunkFile ?? "none";
if (whitelistFile && whitelistFile !== presets[systemName].whitelistFile || whitelistFile === "") presets[systemName].whitelistFile = whitelistFile ?? "none";
// Write the changes
writePresets(presets, callback);
}
}
/**
* Deletes the specified system
*
* @param {string} systemName The name of the system being modified
* @param {function} callback The callback function to be called when the function completes
*/
export const removePreset = (systemName, callback) => {
const presets = this.getPresets();
// Check if a system name was passed
if (systemName in presets) {
delete presets[systemName];
writePresets(presets, callback);
}
}

57
modules/selfUpdater.mjs Normal file
View File

@@ -0,0 +1,57 @@
import simpleGit from 'simple-git';
import { restartService } from './serviceHandler.mjs'
import { launchProcess } from './subprocessHandler.mjs'
const git = simpleGit();
// Function to check for updates
export const checkForUpdates = async () => {
try {
// Fetch remote changes
await git.fetch();
// Get the latest commit hash
const latestCommitHash = await git.revparse(['@{u}']);
// Compare with the local commit hash
const localCommitHash = await git.revparse(['HEAD']);
if (latestCommitHash !== localCommitHash) {
console.log('An update is available. Updating...');
// Check if there have been any changes to the code
const gitStatus = await git.status()
console.log(gitStatus);
if (gitStatus.modified.length > 0){
// There is locally modified code
console.log("There is locally modified code, resetting...");
await git.stash();
await git.reset('hard', ['origin/master']);
}
// Pull the latest changes from the remote repository
await git.pull();
// Run the post-update script
console.log('Running post-update script...');
await launchProcess("bash", ['./post-update.sh'], true);
// Restart the application to apply the updates
console.log('Update completed successfully. Restarting the application...');
restartApplication();
return true
} else {
console.log('The application is up to date.');
return false
}
} catch (error) {
console.error('Error checking for updates:', error);
}
}
// Function to restart the application
const restartApplication = () => {
console.log('Restarting the application...');
restartService('discord-radio-bot');
}

View File

@@ -0,0 +1,58 @@
import { exec } from 'child_process';
/**
* Executes a system command with error handling.
* @param {string} command The command to execute.
* @returns {Promise<{ stdout: string, stderr: string }>} A promise resolving to an object containing stdout and stderr.
*/
const executeCommand = (command) => {
return new Promise((resolve, reject) => {
exec(command, (error, stdout, stderr) => {
if (error) {
console.error(`Command failed with error: ${error.message}`);
resolve({ stdout, stderr });
} else {
resolve({ stdout, stderr });
}
});
});
};
/**
* Starts the given service from the command line.
* @param {string} serviceName The service name to be started.
* @returns {Promise<void>}
*/
export const startService = async (serviceName) => {
try {
await executeCommand(`sudo systemctl start ${serviceName}.service`);
} catch (error) {
console.error(`Failed to start service: ${error.message}`);
}
};
/**
* Restarts the given service from the command line.
* @param {string} serviceName The service name to be restarted.
* @returns {Promise<void>}
*/
export const restartService = async (serviceName) => {
try {
await executeCommand(`sudo systemctl restart ${serviceName}.service`);
} catch (error) {
console.error(`Failed to restart service: ${error.message}`);
}
};
/**
* Stops the given service from the command line.
* @param {string} serviceName The service name to be stopped.
* @returns {Promise<void>}
*/
export const stopService = async (serviceName) => {
try {
await executeCommand(`sudo systemctl stop ${serviceName}.service`);
} catch (error) {
console.error(`Failed to stop service: ${error.message}`);
}
};

52
modules/socketClient.mjs Normal file
View File

@@ -0,0 +1,52 @@
import { io } from "socket.io-client";
import { logIntoServerWrapper, nodeCheckStatus, nodeJoinServer, nodeLeaveServer, nodeGetUsername, nodeCheckDiscordClientStatus, nodeCheckCurrentSystem, nodeUpdate, nodeGetDiscordID } from "./socketClientWrappers.mjs";
/**
* Initialize the socket connection with the server, this will handle disconnects within itself
* @param {Object} localNodeConfig The local node config object
* @returns {any}
*/
export const initSocketConnection = async (localNodeConfig) => {
const serverEndpoint = `http://${localNodeConfig.serverIp}:${localNodeConfig.serverPort}` || 'http://localhost:3000'; // Adjust the server endpoint
const socket = io.connect(serverEndpoint);
// Socket Events ('system' events persay)
// When the socket connects to the node server
socket.on('connect', async () => {
console.log('Connected to the server');
await logIntoServerWrapper(socket, localNodeConfig);
});
// When the socket disconnects from the node server
socket.on('disconnect', () => {
console.log('Disconnected from the server');
});
// Node events/commands
// Requested the node update itself
socket.on('node-update', nodeUpdate);
// Requested to join a discord guild and listen to a system
socket.on('node-join', nodeJoinServer);
// Requested to leave a discord guild
socket.on('node-leave', nodeLeaveServer);
// Requested to get the discord username in a given guild
socket.on('node-get-discord-username', nodeGetUsername);
// Requested to get the ID of the active discord client
socket.on('node-get-discord-id', nodeGetDiscordID);
// Requested to check if the node is connected to VC in a given guild
socket.on('node-check-connected-status', nodeCheckStatus);
// Requested to check if the node has an open discord client
socket.on('node-check-discord-open-client', nodeCheckDiscordClientStatus);
// Requested to get the current listening system
socket.on('node-check-current-system', nodeCheckCurrentSystem);
return socket;
}

View File

@@ -0,0 +1,108 @@
import { checkIfDiscordVCConnected, joinDiscordVC, leaveDiscordVC, getDiscordUsername, checkIfClientIsOpen, getDiscordID } from '../discordAudioBot/pdabWrappers.mjs';
import { getCurrentSystem } from '../op25Handler/op25Handler.mjs';
import { checkForUpdates } from './selfUpdater.mjs';
/**
* Check if the bot has an update available
* @param {any} socketCallback The callback function to return the result
* @callback {boolean} If the node has an update available or not
*/
export const nodeUpdate = async (socketCallback) => {
socketCallback(await checkForUpdates());
}
/**
* Wrapper to log into the server
* @param {any} socket The socket connection with the server
* @param {object} localNodeConfig The local node object
* @returns {any}
*/
export const logIntoServerWrapper = async (socket, localNodeConfig) => {
// Log into the server
socket.emit("node-login", localNodeConfig.node);
// Send an update to the server
sendNodeUpdateWrapper(socket, localNodeConfig);
}
/**
* Send the server an update
* @param {any} socket The socket connection with the server
* @param {object} localNodeConfig The local node object
*/
export const sendNodeUpdateWrapper = async (socket, localNodeConfig) => {
socket.emit('node-update', {
'node': localNodeConfig.node,
'nearbySystems': localNodeConfig.nearbySystems
});
}
/**
* Join the requested server VC and listen to the requested system
* @param {object} joinData The object containing all the information to join the server
*/
export const nodeJoinServer = async (joinData) => {
await joinDiscordVC(joinData);
}
/**
* Leave VC on the requested server
* @param {string} guildId The guild ID to disconnect from VC
*/
export const nodeLeaveServer = async (guildId) => {
await leaveDiscordVC(guildId);
}
/**
* Check if the bot is connected to a discord VC in the given server
* @param {string} guildId The guild id to check the connection status in
* @param {any} socketCallback The callback function to return the result to
* @callback {boolean} If the node is connected to VC in the given guild
*/
export const nodeCheckStatus = async (guildId, socketCallback) => {
socketCallback(await checkIfDiscordVCConnected(guildId));
}
/**
* Get the username of the bot in a given guild
* (there may be a server nickname given to the bot in a certain guild)
* @param {string} guildId The guild id to check the connection status in
* @param {any} socketCallback The callback function to return the result to
* @callback {any}
*/
export const nodeGetUsername = async (guildId, socketCallback) => {
socketCallback(await getDiscordUsername(guildId));
}
/**
* Get the ID of the active client
* @param {any} socketCallback The callback function to return the result to
* @callback {any}
*/
export const nodeGetDiscordID = async (socketCallback) => {
socketCallback(await getDiscordID());
}
/**
* Check if the local node has an open discord client in any server
* @callback {boolean} If the node has an open discord client or not
*/
export const nodeCheckDiscordClientStatus = async (socketCallback) => {
socketCallback(await checkIfClientIsOpen());
}
/**
* Check what system the local node is currently listening to
* @callback {boolean} If the node has an open discord client or not
*/
export const nodeCheckCurrentSystem = async (socketCallback) => {
socketCallback(await getCurrentSystem());
}

View File

@@ -0,0 +1,100 @@
import { spawn } from "child_process";
import dotenv from 'dotenv';
dotenv.config()
/**
* Object to store references to spawned processes.
* @type {Object.<string, import('child_process').ChildProcess>}
*/
const runningProcesses = {};
/**
* Launches a new process if it's not already running.
* @param {string} processName - The name of the process to launch.
* @param {string[]} args - The arguments to pass to the process.
* @param {boolean} waitForClose - Set this to wait to return until the process exits
*/
export const launchProcess = (processName, args, waitForClose = false, pcwd = undefined) => {
if (!runningProcesses[processName]) {
let childProcess;
if (pcwd) {
childProcess = spawn(processName, args, { cwd: pcwd });
}
else {
childProcess = spawn(processName, args);
}
// Store reference to the spawned process
runningProcesses[processName] = childProcess;
// Output the process output in development
var scriptOutput = "";
// Get the stdout from the child process
childProcess.stdout.setEncoding('utf8');
childProcess.stdout.on('data', (data) => {
if (process.env.NODE_ENV === "development") console.log(`Data from ${processName}:`, data);
scriptOutput += data.toString();
});
// Get the stderr from the child process
childProcess.stderr.setEncoding('utf8');
childProcess.stderr.on('data', (data) => {
if (process.env.NODE_ENV === "development") console.log(`Data from ${processName}:`, data);
scriptOutput += data.toString();
})
let code = new Promise(res => {
childProcess.on('exit', (code, signal) => {
// Remove reference to the process when it exits
delete runningProcesses[processName];
console.log(`${processName} process exited with code ${code} and signal ${signal}`);
console.log("Child process console output: ", scriptOutput);
res(code);
})
});
if (waitForClose === true) {
return code
}
console.log(`${processName} process started.`);
} else {
console.log(`${processName} process is already running.`);
}
}
/**
* Checks the status of a process.
* @param {string} processName - The name of the process to check.
* @returns {string} A message indicating whether the process is running or not.
*/
export const checkProcessStatus = (processName) => {
const childProcess = runningProcesses[processName];
if (childProcess) {
// Check if the process is running
if (!childProcess.killed) {
return `${processName} process is running.`;
} else {
return `${processName} process is not running.`;
}
} else {
return `${processName} process is not running.`;
}
}
/**
* Kills a running process.
* @param {string} processName - The name of the process to kill.
*/
export const killProcess = (processName) => {
const childProcess = runningProcesses[processName];
if (childProcess) {
childProcess.kill();
console.log(`${processName} process killed.`);
} else {
console.log(`${processName} process is not running.`);
}
}
export const getRunningProcesses = () => runningProcesses;

109
modules/updateConfig.mjs Normal file
View File

@@ -0,0 +1,109 @@
// Modules
import replace from 'replace-in-file';
class Options {
constructor(key, updatedValue) {
this.files = ".env";
// A regex of the line containing the key in the config file
this.from = new RegExp(`${key}="?(.+)?"?`, "g");
// Check to see if the value is a string and needs to be wrapped in double quotes
if (Array(["string", "number"]).includes(typeof updatedValue)) this.to = `${key}="${updatedValue}",`;
else this.to = `${key}=${updatedValue}`;
}
}
/**
* Wrapper to update the client's saved ID
* @param updatedId The updated ID assigned to the node
*/
export const updateId = async (updatedId) => {
await updateConfig('CLIENT_NUID', updatedId);
process.env.CLIENT_NUID = updatedId;
console.log("Updated NUID to: ", updatedId);
}
/**
* Wrapper to update any or all keys in the client config
*
* @param {Object} runningConfig Running config object
* @param {Object} newConfigObject Object with what keys you wish to update (node object format, will be converted)
* @param {number} newConfigObject.nuid The ID given to the node to update
* @param {string} newConfigObject.name The name of the node
* @param {string} newConfigObject.ip The IP the server can contact the node on
* @param {number} newConfigObject.port The port the server can contact the node on
* @param {string} newConfigObject.location The physical location of the node
* @returns
*/
export function updateClientConfig (runningConfig, newConfigObject) {
var updatedKeys = []
const configKeys = Object.keys(newConfigObject);
if (configKeys.includes("nuid")) {
if (runningConfig.nuid != newConfigObject.nuid) {
this.updateId(newConfigObject.nuid);
updatedKeys.push({ 'CLIENT_NUID': newConfigObject.nuid });
}
}
if (configKeys.includes("name")) {
if (runningConfig.name != newConfigObject.name) {
this.updateConfig('CLIENT_NAME', newConfigObject.name);
updatedKeys.push({ 'CLIENT_NAME': newConfigObject.name });
process.env.CLIENT_NAME = newConfigObject.name;
console.log("Updated name to: ", newConfigObject.name);
}
}
if (configKeys.includes("ip")) {
if (runningConfig.ip != newConfigObject.ip) {
this.updateConfig('CLIENT_IP', newConfigObject.ip);
updatedKeys.push({ 'CLIENT_IP': newConfigObject.ip });
process.env.CLIENT_IP = newConfigObject.ip;
console.log("Updated ip to: ", newConfigObject.ip);
}
}
if (configKeys.includes("port")) {
if (runningConfig.port != newConfigObject.port) {
this.updateConfig('CLIENT_PORT', newConfigObject.port);
updatedKeys.push({ 'CLIENT_PORT': newConfigObject.port });
process.env.CLIENT_PORT = newConfigObject.port;
console.log("Updated port to: ", newConfigObject.port);
}
}
if (configKeys.includes("location")) {
if (runningConfig.location != newConfigObject.location) {
this.updateConfig('CLIENT_LOCATION', newConfigObject.location);
updatedKeys.push({ 'CLIENT_LOCATION': newConfigObject.location });
process.env.CLIENT_LOCATION = newConfigObject.location;
console.log("Updated location to: ", newConfigObject.location);
}
}
return updatedKeys;
}
/**
*
* @param {string} key The config file key to update with the value
* @param {string} value The value to update the key with
*/
export function updateConfig (key, value) {
const options = new Options(key, value);
console.log("Options:", options);
updateConfigFile(options, (updatedFiles) => {
// Do Something
})
}
/**
* Wrapper to write changes to the file
* @param options An instance of the Objects class specified to the key being updated
* @param callback Callback when the files have been modified
*/
function updateConfigFile(options, callback) {
replace(options, (error, changedFiles) => {
if (error) return console.error('Error occurred:', error);
console.log('Updated config file: ', changedFiles);
callback(changedFiles);
});
}