169 Commits

Author SHA1 Message Date
Logan Cusano
ca2815ab8f Improve the update script to use the owner of the git repo 2023-08-06 16:49:31 -04:00
Logan Cusano
556697725a Much improved install script for clients #6 2023-08-06 16:40:10 -04:00
Logan Cusano
b448f04aec Commenting out variables not set by the initial config 2023-08-06 16:27:04 -04:00
Logan Cusano
fae8417b2f Add log file location to the example ENV 2023-08-06 00:46:00 -04:00
Logan Cusano
e06cc4762d Using absolute file path for pdab binaries 2023-08-06 00:45:24 -04:00
Logan Cusano
6deba2bad2 Fix bug that used wrong env var for client port 2023-08-05 03:11:59 -04:00
Logan Cusano
e0d1a4a2fe Update client setup
- get user input for client config
- update pulseaudio setup to run for all users
- add op25 installer
2023-08-05 03:11:32 -04:00
1078faa766 Merge pull request '#37 Implement v1 Web Apps' (#41) from #37-implement-webapps into master
Reviewed-on: #41
2023-08-04 23:46:46 -04:00
Logan Cusano
75580c0547 Fix bug in httprequests, hostname was overwritten 2023-08-04 23:44:28 -04:00
Logan Cusano
880f1ccb01 Implemented v1 admin dashboard 2023-08-04 23:21:35 -04:00
Logan Cusano
76c4d002a0 Update to filter presets function in utils 2023-08-04 22:10:28 -04:00
Logan Cusano
2260deee01 Added filter presets function to utils and code formatted 2023-08-04 22:10:09 -04:00
Logan Cusano
8a0baa5bc9 Small tweaks
- Update local webUI to refresh when joining server to clear modal
- Updated client webUI page title
- Re-added join modal to node page (client & server)
- updated join modal to use node.nearbySystems
2023-07-22 15:01:48 -04:00
Logan Cusano
ec091c0017 Only concat the stored toasts with new toast when there are stored toasts 2023-07-22 14:46:51 -04:00
Logan Cusano
a5996ccfc0 Added page titles to server webUI 2023-07-22 04:21:06 -04:00
Logan Cusano
3b248e36ec Merge branches '#37-implement-webapps' and '#37-implement-webapps' of https://git.vpn.cusano.net/logan/DRB-CnC into #37-implement-webapps 2023-07-22 03:59:20 -04:00
Logan Cusano
abdb725964 Semi functional client webUI
- can update client info for sure
- Working on notifications now
2023-07-22 03:57:50 -04:00
Logan Cusano
167f87128e Check if the presets exist when going to get them
- Return empty object if no preset file is found
2023-07-22 03:57:50 -04:00
Logan Cusano
bc09840dda Ignore local radio presets 2023-07-22 03:57:50 -04:00
Logan Cusano
c680c8fb2c Fixed bug when editing node systems
- Name defaulted to new system name
2023-07-22 03:57:50 -04:00
Logan Cusano
4ceb71bd84 Remove specific presets and left an example file if needed for clients 2023-07-22 03:57:50 -04:00
Logan Cusano
4b86621626 Finalizing Server webUI
Still needed:
- Way to update clients' versions
- Way to delete nodes
- working dashboard
- working search function
2023-07-22 03:57:50 -04:00
Logan Cusano
d847aa4fc7 Add new node if node tries to check in with an ID not in the DB 2023-07-22 03:57:50 -04:00
Logan Cusano
9ff87403b2 Update request node checkin in update node 2023-07-22 03:57:50 -04:00
Logan Cusano
cf9deb4841 Remove unnecessary online param from webUI update node 2023-07-22 03:57:50 -04:00
Logan Cusano
58b4b7ff40 Update design on navbar and sidebar 2023-07-22 03:57:50 -04:00
Logan Cusano
6b4ffc88b3 Implemented functional method to add a new system to a new through web app 2023-07-22 03:57:50 -04:00
Logan Cusano
0f114066a6 Implement functioning method to update systems on web app 2023-07-22 03:57:50 -04:00
Logan Cusano
648782658c Update API and add webapp saving 2023-07-22 03:57:50 -04:00
Logan Cusano
d7ea6bbbd4 Updated NodeCard Design 2023-07-22 03:57:50 -04:00
Logan Cusano
6ffa12911a Update toast creator to display proper date string 2023-07-22 03:57:50 -04:00
Logan Cusano
61d7b69c10 Only show heartbeat toast once HTTP request is complete 2023-07-22 03:57:50 -04:00
Logan Cusano
c14316933b Update heartbeat function location and name 2023-07-22 03:57:50 -04:00
Logan Cusano
f55361575e #37 Working Joining and Leaving 2023-07-22 03:57:50 -04:00
Logan Cusano
c5f7cc1da6 Additional changes for #37
- Updating side bar
- Updating nav bar
- Adding node details page
- Adding controller page
- Updating routes
2023-07-22 03:57:50 -04:00
Logan Cusano
02854fb783 Initial bones for #37 2023-07-22 03:57:50 -04:00
Logan Cusano
4a54be7e51 Update jsDoc in utils 2023-07-22 03:57:50 -04:00
Logan Cusano
cfeea57744 Update jsDoc in nodeController 2023-07-22 03:57:50 -04:00
Logan Cusano
0a8dc75a93 Update adminController to use join/leave command wrappers 2023-07-22 03:57:50 -04:00
Logan Cusano
0426f5eb27 Add jsDoc to leaveServerWrapper 2023-07-22 03:57:50 -04:00
Logan Cusano
d4b974f81b Update join command to accept a specific node ID 2023-07-22 03:57:50 -04:00
Logan Cusano
d05c266f75 Added basic info to readmes 2023-07-22 03:51:46 -04:00
Logan Cusano
57fa6be110 Update Readmes (sort of) 2023-07-22 03:34:37 -04:00
Logan Cusano
f5d58d45da Semi functional client webUI
- can update client info for sure
- Working on notifications now
2023-07-22 03:27:39 -04:00
Logan Cusano
62c0504028 Check if the presets exist when going to get them
- Return empty object if no preset file is found
2023-07-22 01:47:09 -04:00
Logan Cusano
5dd27f0bed Ignore local radio presets 2023-07-22 01:46:19 -04:00
Logan Cusano
e0bae665ed Fixed bug when editing node systems
- Name defaulted to new system name
2023-07-22 01:45:32 -04:00
Logan Cusano
598c802b28 Remove specific presets and left an example file if needed for clients 2023-07-22 01:29:47 -04:00
Logan Cusano
ace762fc76 Finalizing Server webUI
Still needed:
- Way to update clients' versions
- Way to delete nodes
- working dashboard
- working search function
2023-07-22 01:19:54 -04:00
Logan Cusano
75156d059e Add new node if node tries to check in with an ID not in the DB 2023-07-21 18:51:55 -04:00
Logan Cusano
abb833840a Update request node checkin in update node 2023-07-21 18:36:21 -04:00
Logan Cusano
11c8a149bb Remove unnecessary online param from webUI update node 2023-07-21 18:31:41 -04:00
Logan Cusano
9c111eda1a Update design on navbar and sidebar 2023-07-21 18:15:51 -04:00
Logan Cusano
31de3a040d Implemented functional method to add a new system to a new through web app 2023-07-16 23:21:05 -04:00
Logan Cusano
318ee7bf91 Implement functioning method to update systems on web app 2023-07-16 22:30:18 -04:00
Logan Cusano
5428ac6144 Update API and add webapp saving 2023-07-16 18:56:47 -04:00
Logan Cusano
e27dd9d9cb Updated NodeCard Design 2023-07-16 16:47:42 -04:00
Logan Cusano
c0927601b9 Update toast creator to display proper date string 2023-07-16 01:54:13 -04:00
Logan Cusano
ef45cf6539 Only show heartbeat toast once HTTP request is complete 2023-07-16 01:51:25 -04:00
Logan Cusano
23bea5f74e Update heartbeat function location and name 2023-07-16 01:50:01 -04:00
Logan Cusano
fc743cbb46 #37 Working Joining and Leaving 2023-07-16 01:43:13 -04:00
Logan Cusano
e522326576 Additional changes for #37
- Updating side bar
- Updating nav bar
- Adding node details page
- Adding controller page
- Updating routes
2023-07-15 23:30:41 -04:00
Logan Cusano
2e22fa66a6 Initial bones for #37 2023-07-15 18:16:42 -04:00
Logan Cusano
6b37a48061 Update jsDoc in utils 2023-07-15 18:15:17 -04:00
Logan Cusano
5d6d86fa47 Update jsDoc in nodeController 2023-07-15 18:14:53 -04:00
Logan Cusano
c35d3f3fa7 Update adminController to use join/leave command wrappers 2023-07-15 18:14:40 -04:00
Logan Cusano
c38bca4144 Add jsDoc to leaveServerWrapper 2023-07-15 17:58:35 -04:00
Logan Cusano
e6332dffc9 Update join command to accept a specific node ID 2023-07-15 17:58:12 -04:00
Logan Cusano
60b6eb7cda Fix for #40
- Moved general commands to the description for higher character limit
2023-07-13 21:39:55 -04:00
Logan Cusano
eec80f7239 Implement noisegate into recording
- Should remove dead air
- Should save space as it's not recording dead air
2023-07-01 18:24:19 -04:00
Logan Cusano
a58f314475 Lock numpy version for OP25 compatibility 2023-07-01 18:22:20 -04:00
Logan Cusano
a603a53602 Resolve bug when checking if user is in channel 2023-07-01 18:19:53 -04:00
7a0664ad0c Merge pull request '#33 Implement Recording' (#34) from #33-Implement-Recording into master
Reviewed-on: #34
2023-06-25 02:29:07 -04:00
8403c2e391 Merge branch 'master' into #33-Implement-Recording 2023-06-25 02:28:35 -04:00
Logan Cusano
c9890c7cc8 Update give-role filename to match convention 2023-06-25 02:09:04 -04:00
Logan Cusano
bec4c2d743 Add discord commands to allow users to start recording 2023-06-25 02:08:46 -04:00
Logan Cusano
45258d5e4b Updated verbose text for interaction create 2023-06-25 02:08:28 -04:00
Logan Cusano
ffe9c413ba Update client readme
- Basic info for hardware
2023-06-25 01:25:58 -04:00
Logan Cusano
959cfdf7af Implement routes to start and stop client recording 2023-06-25 01:24:46 -04:00
Logan Cusano
97acfc312c Add new python recorder for clients 2023-06-25 01:24:25 -04:00
Logan Cusano
c6cdc0809c Update gitignore to ignore recordings 2023-06-25 01:23:51 -04:00
Logan Cusano
851a9c55fa Fix for #32
- Add the response check when updating client info
2023-06-23 21:43:05 -04:00
Logan Cusano
fb9df3230e Fix for #32
- Add a retry loop when the server is down
2023-06-23 17:55:08 -04:00
Logan Cusano
7163df21e9 Potential final fix for #30 2023-06-23 17:48:28 -04:00
Logan Cusano
47d18c494d Fix misplacement of setting online for checkIn 2023-06-19 02:34:29 -04:00
Logan Cusano
ea2dbd9fb0 Always send online as true when checking in with server 2023-06-19 02:33:06 -04:00
Logan Cusano
9ce77a5be0 Improved fix for #30 2023-06-19 02:29:04 -04:00
Logan Cusano
57881a7e17 Potential fix for #30 2023-06-19 02:26:36 -04:00
Logan Cusano
e350cd6030 Fixing fix of join autocorrect 2023-06-19 00:25:35 -04:00
Logan Cusano
fba0a2a2b2 Fixed bug in join autocorect
- It would add both numbers and the names to the autocorrect
2023-06-19 00:23:29 -04:00
Logan Cusano
83cef63109 Improved number check in join command 2023-06-19 00:20:00 -04:00
Logan Cusano
2390bdc2c6 Update join command to handle updated preset objects 2023-06-19 00:16:58 -04:00
Logan Cusano
93be4ca9dc Update client startup for new nodes
- Still needs to get IP from linux nodes
- Other tasks still, idk
2023-06-19 00:05:27 -04:00
Logan Cusano
d96e6ad519 Updated nodesController to properly update nodes 2023-06-19 00:02:37 -04:00
Logan Cusano
b180f90973 Update recordHelper to use undefined instead of null 2023-06-18 23:40:51 -04:00
Logan Cusano
fd7435c7bc Update requirement versions 2023-06-18 23:21:18 -04:00
Logan Cusano
e062cf5794 Fix bug in HTTP response parsing 2023-06-18 22:53:23 -04:00
Logan Cusano
597546b73d Ensure async functions wait for node contstructor 2023-06-18 22:35:51 -04:00
Logan Cusano
333e7420f4 Removed erroneous indexing on sqlreseponse 2023-06-18 22:32:01 -04:00
Logan Cusano
37a03c5cc6 Forgot to await getting new node 2023-06-18 22:29:16 -04:00
Logan Cusano
d2e9f286e2 Improved logging for server side add new node 2023-06-18 22:25:41 -04:00
Logan Cusano
255b1282ec Improve logging for server SQL requests 2023-06-18 22:22:20 -04:00
Logan Cusano
878e64fa42 Fix bug in return from addNewNode
- Return the function to get a node from the ID since the add new doesn't return node rows
- Update the controller to send the correct key from the updated object
2023-06-18 19:13:03 -04:00
Logan Cusano
7a040a8e97 Handle if new node is added with no nearby systems 2023-06-18 19:06:14 -04:00
Logan Cusano
8dffeccf83 Fix bug with 'connected' key when adding new node to the server 2023-06-18 19:02:15 -04:00
Logan Cusano
2108a3b92b Improved error response for new node 2023-06-18 17:13:29 -04:00
Logan Cusano
960b801dd2 Working on initial startup for new clients 2023-06-18 16:36:38 -04:00
Logan Cusano
dd5b442377 Update client clientController to better handle no ID 2023-06-18 16:26:55 -04:00
Logan Cusano
c5a7131063 Improve validation when checking for nodeId 2023-06-18 15:31:15 -04:00
Logan Cusano
5d54f07af4 Enabled nodeMonitorService
- Updated logging for nodeMonitorService to use it's own debugBuilder
2023-06-18 14:56:54 -04:00
Logan Cusano
24faa5279d #29
- Update debugBuilder to inspect objects instead of stringify
- Moved tthe debug entry for saving post to after the post is validated
2023-06-18 14:49:15 -04:00
Logan Cusano
79d2ca1887 #6 Removed copy of env
- Should be done manually and edited before running setup script
2023-06-17 23:37:26 -04:00
Logan Cusano
c2b4b4bfa1 #16
- Allow bots to display the preset they are listening to
2023-06-17 22:02:09 -04:00
Logan Cusano
d8a697e583 #13
- Added command to allow users to give other users their group
- Shows all groups as options but will not let user add another user if the caller doesn't have the role
2023-06-17 21:49:46 -04:00
Logan Cusano
44caa11f7c Fixed typo in remove description 2023-06-17 21:28:33 -04:00
Logan Cusano
dc92b07426 Implemented function to remove commands from a given bot and guild 2023-06-17 19:37:13 -04:00
Logan Cusano
92f4caad0c Improve the join node verification system 2023-06-17 19:25:47 -04:00
Logan Cusano
b888a9233d Improve the response when a user requests a bot to join 2023-06-17 19:01:55 -04:00
Logan Cusano
b4e27162aa Update to mysql2 2023-06-17 18:40:09 -04:00
Logan Cusano
bfda15866e Streamlined joining and leaving autocomplete
- Removed custom command builder as it's no longer needed with autocomplete
2023-06-17 17:55:27 -04:00
Logan Cusano
f4475dc9d7 #19
- Update the wrapper called when a feed encounters an error
    - Will now use a more robus backoff system
    - Waits in increments of 30 seconds
    - Keeps track of ignored attempts and 'count'

- Updated wrapper to remove source from backoff list
    - Now removes the object after the first attempt irrespective of deletion station
2023-06-16 23:26:38 -04:00
Logan Cusano
c4650a9e99 Make the bot option in the leave command required 2023-06-16 22:02:54 -04:00
Logan Cusano
f5e119d845 Bugfixes and functional #7 & #9
- #7 needs to error check more
- both need to be cleaned up
2023-06-11 04:40:40 -04:00
Logan Cusano
e8d68b2da7 Initial #7 & #9
- Working commands
- Keeps track of open connections
2023-06-10 22:16:39 -04:00
Logan Cusano
041e0d485d Fix error status in client join 2023-06-10 20:46:43 -04:00
Logan Cusano
fc11324714 Add function to get all client IDs from JSON file #7 2023-06-04 00:24:50 -04:00
Logan Cusano
c6c048c919 Update default command with autocomplete 2023-06-03 23:35:07 -04:00
Logan Cusano
8ab611836b Allow commands to use autocomplete 2023-06-03 23:31:27 -04:00
7d8ad68e27 Merge pull request 'Add join command to server #7' (#15) from Add-join-command-to-server-#7 into master
Reviewed-on: #15
2023-06-03 23:05:39 -04:00
200ca9c926 Merge branch 'master' into Add-join-command-to-server-#7 2023-06-03 23:05:12 -04:00
Logan Cusano
ff8e86cc3a Updated client setup script
- Create a copy of the .env example
- Updated the installed packages to allow for installation
2023-06-03 23:02:41 -04:00
Logan Cusano
6b12c3e3df Remove unused keys from example .env file 2023-06-03 23:01:47 -04:00
Logan Cusano
fa2f28207e wrapping up join command
- API untested
2023-06-03 23:00:50 -04:00
Logan Cusano
5c8414b4d8 Moved to issues in git 2023-06-03 22:58:31 -04:00
Logan Cusano
edaeb261f7 Add additional info on connection status to nodeObject 2023-06-03 19:00:16 -04:00
Logan Cusano
c31ccff5ca Added JSDoc to Join wrapper and updated wrapper to also take just a client ID string 2023-06-03 16:03:07 -04:00
Logan Cusano
d2186e9471 Added a join command #7
- Added a JSON example for Known Client IDs
- Implemented a custom slash command builder to add the available presets as options in the discord command
2023-06-03 15:47:07 -04:00
Logan Cusano
07743cf8a3 Updated requirements and versions 2023-06-03 15:43:15 -04:00
Logan Cusano
18afa7c058 Added extra logging when deploying commands 2023-06-03 15:42:40 -04:00
Logan Cusano
a5cff9ec7e Check if the returned data from HTTP is valid JSON and parse if so, return the string if not 2023-06-03 15:42:23 -04:00
Logan Cusano
9450b78bd4 Updated all functions to return nodeObjects instead of SQL response 2023-06-03 15:41:29 -04:00
Logan Cusano
5757c51fa3 Added new utils
- isJsonString
    - This can be used to check if a string is valid json before parsing it
- getMembersInRole
    - This can be used to check online/offline/all members in a role
- (unused) SanitizePresetName
2023-06-03 15:40:48 -04:00
Logan Cusano
fa91cbc887 Used env var for the listening port 2023-06-03 15:39:16 -04:00
Logan Cusano
7fbaf31335 Updated server intents 2023-06-03 15:38:40 -04:00
Logan Cusano
0280cb5384 Update gitignore 2023-06-03 15:38:24 -04:00
Logan Cusano
a298be40d5 Accidentally set the wrong variable for the device ID 2023-06-03 15:32:08 -04:00
Logan Cusano
43d60a748b Remove device ID requirement for API
- The device ID is handled by the .env file
2023-06-03 15:28:46 -04:00
Logan Cusano
51f517cae5 Fixed node '^=' to python '>=' 2023-06-03 15:03:18 -04:00
Logan Cusano
06cb2cc352 Fix logging namespace and windows launch 2023-06-03 15:00:42 -04:00
Logan Cusano
5ce525f2b5 Updating install script #6 2023-06-03 02:51:19 -04:00
Logan Cusano
69fdc63983 Fixing linux bug, added noisegate switch to the call 2023-05-27 21:34:19 -04:00
Logan Cusano
a9d3c33af2 Improved client bot logging 2023-05-27 21:17:31 -04:00
Logan Cusano
3719fce86a Update client package.json 2023-05-27 18:25:12 -04:00
Logan Cusano
ba927bae8c Implement install and update system for the bot
- LINUX OR WINDOW WSL ONLY
2023-05-27 17:00:57 -04:00
79fe542143 Merge pull request 'Implement Python Discord Bot to Handle Voice Connection' (#5) from feature/implement-discordpy into master
Reviewed-on: #5
2023-05-27 16:19:38 -04:00
Logan Cusano
7512c8e1df Removing the static ENV var for nearby systems 2023-05-21 15:22:43 -04:00
Logan Cusano
c882fb63d3 Moving PDAB to pdab 2023-05-20 15:24:28 -04:00
Logan Cusano
7fc61bbf2e renaming PDAB to pdab to try to fix git issues 2023-05-20 15:20:53 -04:00
Logan Cusano
e1c2ce6484 Updating and streamlining radio controller side 2023-05-20 15:18:50 -04:00
Logan Cusano
c4070cc420 Initial working radio controller for OP25 2023-05-20 14:31:43 -04:00
Logan Cusano
0f003f907e Discord voice bot handler working 2023-05-20 00:01:12 -04:00
Logan Cusano
e7b802839e Initial removal of internal discord bot 2023-05-18 22:53:25 -04:00
Logan Cusano
48999e0d63 Resolved bug in Client with .env config migration 2023-05-07 19:31:53 -04:00
Logan Cusano
2c25be1de7 Removed Embedded discord 2023-05-07 19:31:33 -04:00
Logan Cusano
cf04e37f89 Implement example .env files 2023-05-07 04:48:19 -04:00
Logan Cusano
d04cc8d5b1 Update gitignore for .env files 2023-05-07 04:43:46 -04:00
Logan Cusano
4662f37a72 Removing real .env files 2023-05-07 04:43:00 -04:00
Logan Cusano
be34c5381b Removing real .envs 2023-05-07 04:42:05 -04:00
Logan Cusano
ed79403a9b Remove config file for client, moved to .env 2023-05-07 04:40:46 -04:00
111 changed files with 5722 additions and 7705 deletions

5
.gitignore vendored
View File

@@ -5,4 +5,7 @@ node_modules/
# Development files
*.log
*.txt
*.env
*.wav
!requirements.txt
*testOP25Dir/

View File

@@ -1,8 +0,0 @@
DEBUG="client:*"
TOKEN=""
# Bot Config
APPLICATION_ID=""
GUILD_ID=""
# Audio Config
AUDIO_DEVICE_ID="1"
AUDIO_DEVICE_NAME="VoiceMeeter VAIO3 Output (VB-Au"

23
Client/.env.example Normal file
View File

@@ -0,0 +1,23 @@
DEBUG="client:*"
# Audio Config
AUDIO_DEVICE_ID=""
AUDIO_DEVICE_NAME=""
# Client Config
CLIENT_ID=0
CLIENT_NAME=""
CLIENT_IP=""
CLIENT_PORT=3010
CLIENT_LOCATION=""
CLIENT_ONLINE=true
# Configuration for the connection to the server
SERVER_IP=""
SERVER_HOSTNAME=""
SERVER_PORT=3000
# Configuration of the local OP25 application
#OP25_BIN_PATH=""
# Logfile location config
#LOG_LOCATION=""

1
Client/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
*radioPresets.json

View File

@@ -5,38 +5,19 @@ var cookieParser = require('cookie-parser');
var logger = require('morgan');
var http = require('http');
require('dotenv').config();
const fs = require('fs');
const { DebugBuilder } = require("./utilities/debugBuilder");
const deployCommands = require('./utilities/deployCommands');
const { checkIn } = require("./controllers/clientController");
var indexRouter = require('./routes/index');
var botRouter = require('./routes/bot');
var clientRouter = require('./routes/client');
var radioRouter = require('./routes/radio');
var { attachRadioSessionToRequest } = require('./controllers/radioController');
const log = new DebugBuilder("client", "app");
const {
Client,
Events,
Collection,
GatewayIntentBits,
MessageActionRow,
MessageButton
} = require('discord.js');
var app = express();
var discordToken = process.env.TOKEN;
var port = process.env.HTTP_PORT || '3010';
const discordClient = new Client({
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.MessageContent,
GatewayIntentBits.GuildVoiceStates
]
});
var port = process.env.CLIENT_PORT || '3010';
// view engine setup
app.set('views', path.join(__dirname, 'views'));
@@ -53,16 +34,13 @@ app.use(express.static(path.join(__dirname, 'public')));
app.use('/', indexRouter);
// Discord bot control route
app.use('/bot', (req, res, next) => {
req.discordClient = discordClient; // Add the discord client to bot requests to be used downstream
next();
}, botRouter);
app.use('/bot', attachRadioSessionToRequest, botRouter);
// Local client control route
app.use("/client", clientRouter);
// Local radio controller route
app.use("/radio", radioRouter);
app.use("/radio", attachRadioSessionToRequest, radioRouter);
// catch 404 and forward to error handler
app.use((req, res, next) => {
@@ -116,54 +94,8 @@ async function runHTTPServer() {
})
}
// Discord bot config
// Setup commands for the Discord bot
discordClient.commands = new Collection();
const commandsPath = path.join(__dirname, 'commands');
const commandFiles = fs.readdirSync(commandsPath).filter(file => file.endsWith('.js'));
//const commandFiles = fs.readdirSync('./commands').filter(file => file.endsWith('.js'));
for (const file of commandFiles) {
const filePath = path.join(commandsPath, file);
const command = require(filePath);
log.DEBUG("Importing command: ", command.data.name);
// Set a new item in the Collection
// With the key as the command name and the value as the exported module
discordClient.commands.set(command.data.name, command);
}
// Run when the bot is ready
discordClient.on('ready', () => {
log.DEBUG(`Discord server up and running with client: ${discordClient.user.tag}`);
log.INFO(`Logged in as ${discordClient.user.tag}!`);
// Deploy slash commands
log.DEBUG("Deploying slash commands");
deployCommands.deploy(discordClient.user.id, discordClient.guilds.cache.map(guild => guild.id));
log.DEBUG(`Starting HTTP Server`);
log.DEBUG(`Starting HTTP Server`);
runHTTPServer();
log.DEBUG("Checking in with the master server")
checkIn();
});
// Setup any additional event handlers
const eventsPath = path.join(__dirname, 'events');
if (fs.existsSync(eventsPath)) {
const eventFiles = fs.readdirSync(eventsPath).filter(file => file.endsWith('.js'));
if (eventFiles.length > 0) {
for (const file of eventFiles) {
const filePath = path.join(eventsPath, file);
const event = require(filePath);
if (event.once) {
discordClient.once(event.name, (...args) => event.execute(...args));
} else {
discordClient.on(event.name, (...args) => event.execute(...args));
}
}
}
}
discordClient.login(discordToken); //Load Client Discord Token
checkIn(true);

View File

@@ -1,76 +0,0 @@
#!/usr/bin/env node
/**
* Module dependencies.
*/
const app = require('../app');
// Debug
const { DebugBuilder } = require("../utilities/debugBuilder.js");
const log = new DebugBuilder("client", "www");
const http = require('http');
const config = require('../config/clientConfig');
const clientController = require('../controllers/clientController');
/**
* Get port from environment and store in Express.
*/
app.set('port', config.clientConfig.port);
/**
* Create HTTP server.
*/
const server = http.createServer(app);
/**
* Listen on provided port, on all network interfaces.
*/
server.listen(config.clientConfig.port);
server.on('error', onError);
server.on('listening', onListening);
/**
* Event listener for HTTP server "error" event.
*/
function onError(error) {
if (error.syscall !== 'listen') {
throw error;
}
const bind = typeof port === 'string'
? 'Pipe ' + port
: 'Port ' + port;
// handle specific listen errors with friendly messages
switch (error.code) {
case 'EACCES':
console.error(bind + ' requires elevated privileges');
process.exit(1);
break;
case 'EADDRINUSE':
console.error(bind + ' is already in use');
process.exit(1);
break;
default:
throw error;
}
}
/**
* Event listener for HTTP server "listening" event.
*/
function onListening() {
const addr = server.address();
const bind = typeof addr === 'string'
? 'pipe ' + addr
: 'port ' + addr.port;
log.DEBUG('Listening on ' + bind);
// check in with the server to add this node or come back online
clientController.checkIn();
}

View File

@@ -1,17 +0,0 @@
// Debug
const { DebugBuilder } = require("../utilities/debugBuilder");
const log = new DebugBuilder("client", "ping");
// Modules
const { SlashCommandBuilder } = require('discord.js');
const { join } = require("../controllers/commandController")
module.exports = {
data: new SlashCommandBuilder()
.setName('join')
.setDescription('Join a voice channel'),
example: "join",
isPrivileged: false,
async execute(interaction) {
await join({ interaction: interaction });
}
}

View File

@@ -1,17 +0,0 @@
// Debug
const { DebugBuilder } = require("../utilities/debugBuilder.js");
const log = new DebugBuilder("client", "leave");
// Modules
const { SlashCommandBuilder } = require('discord.js');
const { leave } = require("../controllers/commandController")
module.exports = {
data: new SlashCommandBuilder()
.setName('leave')
.setDescription('Leave a voice channel'),
example: "leave",
isPrivileged: false,
async execute(interaction) {
await leave({ interaction: interaction })
}
}

View File

@@ -1,28 +0,0 @@
// Utilities
const { replyToInteraction } = require('../utilities/messageHandler.js');
const { SlashCommandBuilder } = require('discord.js');
const { DebugBuilder } = require("../utilities/debugBuilder");
const log = new DebugBuilder("client", "ping");
module.exports = {
data: new SlashCommandBuilder()
.setName('ping')
.setDescription('Replies with your input!'),
/*
.addStringOption(option =>
option.setName('input')
.setDescription('The input to echo back')
.setRequired(false)
.addChoices()),
*/
example: "ping",
isPrivileged: false,
async execute(interaction) {
try{
await replyToInteraction(interaction, "Pong! I have Aids and now you do too!"); // TODO - Add insults as the response to this command
}catch(err){
log.ERROR(err)
//await interaction.reply(err.toString());
}
}
};

View File

@@ -1,19 +0,0 @@
// Debug
const { DebugBuilder } = require("../utilities/debugBuilder.js");
const log = new DebugBuilder("client", "status");
// Modules
const { status } = require('../controllers/commandController');
// Utilities
const { SlashCommandBuilder } = require('discord.js');
module.exports = {
data: new SlashCommandBuilder()
.setName('status')
.setDescription('Check the status of the bot'),
example: "status",
isPrivileged: false,
async execute(interaction) {
await status({ interaction: interaction });
}
}

View File

@@ -1,23 +0,0 @@
// Core config settings for the node, these are the settings that are checked with the server
const path = require("path");
exports.clientConfig = {
"id": 13,
"name": "boilin balls in the hall",
"ip": "172.16.100.150",
"port": 3010,
"location": "the house",
"nearbySystems": ["Westchester Cty. Simulcast"],
"online": true
}
// Configuration for the connection to the server
exports.serverConfig = {
"ip": "172.16.100.108",
"hostname": "localhost",
"port": 3000
}
// Configuration of the local OP25 application
exports.radioAppConfig = {
"bin": "H:/Logan/Projects/Discord-Radio-Bot-CnC/Client/.idea/testOP25Dir/multi_rx.py"
}

View File

@@ -1,16 +0,0 @@
// Core config settings for the node, these are the settings that are checked with the server
exports.nodeConfig = {
"id": 0,
"name": "",
"ip": "",
"port": 0,
"location": "",
"nearbySystems": {
"System Name": {
"frequencies": [],
"mode": "",
"trunkFile": ""
}
},
"online": false
}

View File

@@ -1 +0,0 @@
{"Westchester Cty. Simulcast":{"frequencies":[470575000,470375000,470525000,470575000,470550000],"mode":"p25","trunkFile":"trunk.tsv"},"coppies":{"frequencies":[154875000],"mode":"nbfm","trunkFile":"none"}}

View File

@@ -0,0 +1,18 @@
{
"Default P25 System Name": {
"frequencies": [
155344000,
155444000,
155555000
],
"mode": "p25",
"trunkFile": "trunk.tsv"
},
"Default NBFM System": {
"frequencies": [
154690000
],
"mode": "nbfm",
"trunkFile": "none"
}
}

View File

@@ -1,88 +0,0 @@
// Config
const { getDeviceID } = require('../utilities/configHandler.js');
// Modules
const portAudio = require('naudiodon');
const { returnAlsaDeviceObject } = require("../utilities/executeConsoleCommands.js");
// Debug
const { DebugBuilder } = require("../utilities/debugBuilder.js");
// Global Vars
const log = new DebugBuilder("client", "audioController");
/**
* Checks to make sure the selected audio device is available and returns the device object (PortAudio Device Info)
* At least one option must be supplied, it will prefer ID to device name
*
* @param deviceName The name of the device being queried
* @param deviceId The ID of the device being queried
* @returns {unknown}
*/
async function confirmAudioDevice({deviceName = undefined, deviceId = undefined}){
const deviceList = await getAudioDevices();
if (!deviceName && !deviceId) throw new Error("No device given");
let confirmedDevice;
if (deviceId) confirmedDevice = deviceList.find(device => device.id === deviceId);
if (deviceName) confirmedDevice = deviceList.find(device => device.name === deviceName);
log.DEBUG("Confirmed Audio Device: ", confirmedDevice);
return confirmedDevice;
}
exports.confirmAudioDevice = confirmAudioDevice;
/**
* Return a list of the audio devices connected with input channels
*
* @returns {unknown[]}
*/
async function getAudioDevices(){
// Exec output contains both stderr and stdout outputs
//const deviceList = await returnAlsaDeviceObject();
const deviceList = portAudio.getDevices().map((device) => {
if (device.maxInputChannels > 2) {
return device;
}
else {
return null;
}
}).filter(Boolean);
log.VERBOSE("Device List: ", deviceList);
return deviceList;
}
exports.getAudioDevices = getAudioDevices;
/**
* Create and return the audio instance from the saved settings
* TODO Allow the client to save and load these settings dynamically
*
* @returns new portAudio.AudioIO
*/
async function createAudioInstance() {
const selectedDevice = await confirmAudioDevice({deviceId: getDeviceID()});//{deviceName: "VoiceMeeter VAIO3 Output (VB-Au"});
log.DEBUG("Device selected from config: ", selectedDevice);
// Create an instance of AudioIO with outOptions (defaults are as below), which will return a WritableStream
return new portAudio.AudioIO({
inOptions: {
channelCount: 2,
sampleFormat: portAudio.SampleFormat16Bit,
sampleRate: 48000,
deviceId: selectedDevice.id, // Use -1 or omit the deviceId to select the default device
closeOnError: false, // Close the stream if an audio error is detected, if set false then just log the error
framesPerBuffer: 20, //(48000 / 1000) * 20, //(48000 * 16 * 2) / 1000 * 20 // (48000 * (16 / 8) * 2) / 60 / 1000 * 20 //0.025 * 48000 / 2
highwaterMark: 3840,
},
});
/*
return new alsaInstance({
channels: 2,
format: "U8",
rate: 48000,
device: selectedDevice.name ?? "default", // Omit the deviceId to select the default device
periodSize: 100, //(48000 / 1000) * 20, //(48000 * 16 * 2) / 1000 * 20 // (48000 * (16 / 8) * 2) / 60 / 1000 * 20 //0.025 * 48000 / 2
periodTime: undefined,
// highwaterMark: 3840
});
*/
}
exports.createAudioInstance = createAudioInstance;

View File

@@ -1,98 +1,159 @@
// Debug
const { DebugBuilder } = require("../utilities/debugBuilder.js");
const log = new DebugBuilder("client", "clientController");
const log = new DebugBuilder("client", "botController");
const botLog = new DebugBuilder("client", "botController:bot");
// Modules
const { status, join, leave } = require("./commandController")
const spawn = require('child_process').spawn;
const { resolve } = require("path");
require('dotenv').config();
const { closeProcessWrapper } = require("../utilities/utilities");
/**
* Get an object of client guilds
* @param req The express request which includes the discord client
* @returns
*/
function getGuilds(req) {
return req.discordClient.guilds.cache.map(guild => guild.id)
}
/**
* Get an object of the channels in a guild
* @param {*} guildId The Guild ID to check the channels of
* @param {*} req The request object to use to check the discord client
*/
function getChannels(guildId, req) {
const guild = req.discordClient.guilds.find(guildId);
log.DEBUG("Found Guild channels with guild", guild.channels, guild);
return guild.channels;
}
/**
* Check to see if a given guild has a given channel
* @param {*} guildId The guild ID to check if the channel exists
* @param {*} channelId The channel ID to check if exists in the guild
* @param {*} req The express request param to use the discord client
* @returns {true|false}
*/
function checkIfGuildHasChannel(guildId, channelId, req){
const guildChannels = getChannels(guildId, req)
const checkedChannel = guildChannels.find(c => c.id === channelId);
if (!checkedChannel) return false;
return true;
}
function getGuildFromChannel(channelId, req){
const channel = req.discordClient.channels.cache.get(channelId);
if (!channel) return new Error("Error getting channel from client");
if (channel.guild) return channel.guild;
return new Error("No Guild found with the given ID");
}
// Global vars
let pythonProcess;
let recordingProcess;
/**
* Get Status of the discord process
*/
exports.getStatus = (req, res) => {
log.INFO("Getting the status of the bot");
guildIds = getGuilds(req);
log.DEBUG("Guild IDs: ", guildIds);
var guildStatuses = []
for (const guildId of guildIds){
status({guildID: guildId, callback: (statusObj) => {
log.DEBUG("Status Object string: ", statusObj);
guildStatuses.push(statusObj);
}});
}
return res.status(200).json(guildStatuses);
if (pythonProcess) return res.sendStatus(200);
return res.sendStatus(201);
}
/**
* Start the bot and join the server and preset specified
*/
exports.joinServer = (req, res) => {
const channelId = req.body.channelID;
exports.joinServer = async (req, res) => {
if (!req.body.clientId || !req.body.channelId) return res.status(500).json({"message": "You must include the client ID (discord token), channel ID (The discord ID of the channel to connect to)"});
const deviceId = process.env.AUDIO_DEVICE_ID;
const channelId = req.body.channelId;
const clientId = req.body.clientId;
const presetName = req.body.presetName;
const guildObj = getGuildFromChannel(channelId, req);
const NGThreshold = req.body.NGThreshold ?? 50
if (!channelId || !presetName || !guildObj) return res.status(400).json({'message': "Request does not have all components to proceed"});
// Joining the discord server
log.INFO("Join requested to: ", deviceId, channelId, clientId, presetName, NGThreshold);
if (process.platform === "win32") {
log.DEBUG("Starting Windows Python");
pythonProcess = await spawn('python.exe', [resolve(__dirname, "../pdab/main.py"), deviceId, channelId, clientId, '-n', NGThreshold, '-p', presetName ], { cwd: resolve(__dirname, "../pdab/").toString() });
//pythonProcess = await spawn('C:\\Python310\\python.exe', [resolve(__dirname, "../PDAB/main.py"), deviceId, channelId, clientId, NGThreshold ]);
}
else {
log.DEBUG("Starting Linux Python");
pythonProcess = await spawn('python3', [resolve(__dirname, "../pdab/main.py"), deviceId, channelId, clientId,'-n', NGThreshold, '-p', presetName ], { cwd: resolve(__dirname, "../pdab/") });
}
// join the sever
join({guildID: guildObj.id, guildObj: guildObj, channelID: channelId, callback: () => {
return res.sendStatus(202);
}});
log.VERBOSE("Python Process: ", pythonProcess);
let fullOutput;
pythonProcess.stdout.setEncoding('utf8');
pythonProcess.stdout.on("data", (data) => {
botLog.VERBOSE("From Process: ", data);
fullOutput += data.toString();
});
pythonProcess.stderr.on('data', (data) => {
botLog.VERBOSE(`stderr: ${data}`);
fullOutput += data.toString();
});
pythonProcess.on('close', (code) => {
log.DEBUG(`child process exited with code ${code}`);
log.VERBOSE("Full output from bot: ", fullOutput);
});
pythonProcess.on("error", (code, signal) => {
log.ERROR("Error from the discord bot process: ", code, signal);
});
// Starting the radio application
return res.sendStatus(200);
}
/**
* Leaves the server if it's in one
*/
exports.leaveServer = (req, res) => {
exports.leaveServer = async (req, res) => {
log.INFO("Leaving the server");
const guildIds = getGuilds(req);
log.DEBUG("Guild IDs: ", guildIds);
for (const guildId of guildIds){
leave({guildID: guildId, callback: (response) => {
log.DEBUG("Response from leaving server on guild ID", guildId, response);
}});
}
if (!pythonProcess) return res.sendStatus(200)
pythonProcess = await closeProcessWrapper(pythonProcess);
return res.sendStatus(202);
}
/**
* Start a recording of what the bot is listening to, if it's currently connected
*
* @param {*} req
* @param {*} res
*/
exports.startRecording = async (req, res) => {
log.INFO("Starting recording")
//if (pythonProcess === undefined) return res.sendStatus(204);
if (!recordingProcess === undefined) return res.sendStatus(202);
const deviceId = process.env.AUDIO_DEVICE_ID;
const filename = "./recordings/" + new Date().toJSON().slice(0,10) + ".wav";
// Joining the server to record
log.INFO("Start recording: ", deviceId, filename);
if (process.platform === "win32") {
log.DEBUG("Starting Windows Python");
recordingProcess = await spawn('python', [resolve(__dirname, "../pdab/recorder.py"), deviceId, filename ], { cwd: resolve(__dirname, "../pdab/").toString() });
}
else {
log.DEBUG("Starting Linux Python");
recordingProcess = await spawn('python3', [resolve(__dirname, "../pdab/recorder.py"), deviceId, filename ], { cwd: resolve(__dirname, "../pdab/") });
}
await this.getProcessOutput(recordingProcess);
return res.sendStatus(200);
}
/**
* Stop the recording if the bot is currently recording
*
* @param {*} req
* @param {*} res
*/
exports.stopRecording = async (req, res) => {
log.INFO("Stopping recording the server");
if (!recordingProcess) return res.sendStatus(202)
recordingProcess = await closeProcessWrapper(recordingProcess);
return res.sendStatus(200);
}
/**
* Get the output of a running process
*
* @param {*} runningProcess
* @returns
*/
exports.getProcessOutput = async (runningProcess) => {
let fullOutput;
runningProcess.stdout.setEncoding('utf8');
runningProcess.stdout.on("data", (data) => {
botLog.VERBOSE("From Process: ", data);
fullOutput += data.toString();
});
runningProcess.stderr.on('data', (data) => {
botLog.VERBOSE(`stderr: ${data}`);
fullOutput += data.toString();
});
runningProcess.on('close', (code) => {
log.DEBUG(`child process exited with code ${code}`);
log.VERBOSE("Full output from bot: ", fullOutput);
});
runningProcess.on("error", (code, signal) => {
log.ERROR("Error from the process: ", code, signal);
});
}

View File

@@ -2,14 +2,17 @@
const { DebugBuilder } = require("../utilities/debugBuilder.js");
const log = new DebugBuilder("client", "clientController");
// Configs
const config = require("../config/clientConfig");
require('dotenv').config();
const modes = require("../config/modes");
// Modules
const { executeAsyncConsoleCommand } = require("../utilities/executeConsoleCommands.js");
const { executeAsyncConsoleCommand, BufferToJson, nodeObject } = require("../utilities/utilities");
// Utilities
const { updateId, updateConfig } = require("../utilities/updateConfig");
const updatePreset = require("../utilities/updatePresets");
const requests = require("../utilities/httpRequests");
const { getFullConfig } = require("../utilities/configHandler");
const { updateId, updateConfig, updateClientConfig } = require("../utilities/updateConfig");
const { updatePreset, addNewPreset, getPresets, removePreset } = require("../utilities/updatePresets");
const { onHttpError, requestOptions, sendHttpRequest } = require("../utilities/httpRequests");
var runningClientConfig = getFullConfig()
/**
* Check the body for the required fields to update or add a preset
@@ -19,11 +22,11 @@ const requests = require("../utilities/httpRequests");
* @returns {*}
*/
function checkBodyForPresetFields(req, res, callback) {
if (!req.body?.systemName) return res.status(403).json({"message": "No system in the request"});
if (!req.body?.frequencies && Array.isArray(req.body.frequencies)) return res.status(403).json({"message": "No frequencies in the request or type is not an array"});
if (!req.body?.mode && typeof req.body.mode === "string") return res.status(403).json({"message": "No mode in the request"});
if (!req.body?.systemName) return res.status(403).json({ "message": "No system in the request" });
if (!req.body?.frequencies && Array.isArray(req.body.frequencies)) return res.status(403).json({ "message": "No frequencies in the request or type is not an array" });
if (!req.body?.mode && typeof req.body.mode === "string") return res.status(403).json({ "message": "No mode in the request" });
if (!req.body?.trunkFile) {
if (modes.digitalModes.includes(req.body.mode)) return res.status(403).json({"message": "No trunk file in the request but digital mode specified. If you are not using a trunk file for this frequency make sure to specify 'none' for trunk file in the request"})
if (modes.digitalModes.includes(req.body.mode)) return res.status(403).json({ "message": "No trunk file in the request but digital mode specified. If you are not using a trunk file for this frequency make sure to specify 'none' for trunk file in the request" })
// If there is a value keep it but if not, add nothing so the system can update that key (if needed)
req.body.trunkFile = req.body.trunkFile ?? "none";
}
@@ -32,7 +35,6 @@ function checkBodyForPresetFields(req, res, callback) {
}
async function checkLocalIP() {
let ipAddr;
if (process.platform === "win32") {
// Windows
var networkConfig = await executeAsyncConsoleCommand("ipconfig");
@@ -66,58 +68,109 @@ async function checkLocalIP() {
* Checks the config file for all required fields or gets and updates the required fields
*/
exports.checkConfig = async function checkConfig() {
if (!config.clientConfig.ip) {
if (!runningClientConfig.id || runningClientConfig.id == 0 || runningClientConfig.id == '0') {
await updateId(0);
runningClientConfig.id = 0;
}
if (!runningClientConfig.ip) {
const ipAddr = await checkLocalIP();
updateConfig('ip', ipAddr);
config.clientConfig.ip = ipAddr;
await updateConfig('CLIENT_IP', ipAddr);
runningClientConfig.ip = ipAddr;
}
if(!config.clientConfig.name) {
if (!runningClientConfig.name) {
const lastOctet = await String(checkLocalIP()).spit('.')[-1];
const clientName = `Radio-Node-${lastOctet}`;
updateConfig('name', clientName);
config.clientConfig.name = clientName;
const name = `Radio-Node-${lastOctet}`;
await updateConfig('CLIENT_NAME', name);
runningClientConfig.name = name;
}
if(!config.clientConfig.port) {
if (!runningClientConfig.port) {
const port = 3010;
updateConfig('port', port);
config.clientConfig.port = port;
await updateConfig('CLIENT_PORT', port);
runningClientConfig.port = port;
}
}
/** Check in with the server
* If the bot has a saved ID, check in with the server to update any information or just check back in
* If the bot has a saved ID, check in with the server to get any updated information or just check back in
* If the bot does not have a saved ID, it will attempt to request a new ID from the server
*
* @param {boolean} update If set to true, the client will update the server to it's config, instead of taking the server's config
*/
exports.checkIn = async () => {
exports.checkIn = async (update = false) => {
let reqOptions;
await this.checkConfig();
// Check if there is an ID found, if not add the node to the server. If there was an ID, check in with the server to make sure it has the correct information
if (config.clientConfig.id === 0) {
try {
if (!runningClientConfig?.id || runningClientConfig.id == 0) {
// ID was not found in the config, creating a new node
reqOptions = new requests.requestOptions("/nodes/newNode", "POST");
requests.sendHttpRequest(reqOptions, JSON.stringify(config.clientConfig), (responseObject) => {
// Update the client's ID if the server accepted it
if (responseObject.statusCode === 202) {
config.clientConfig.id = responseObject.body.nodeId;
updateId(responseObject.body.nodeId);
reqOptions = new requestOptions("/nodes/newNode", "POST");
sendHttpRequest(reqOptions, JSON.stringify(runningClientConfig), async (responseObject) => {
// Check if the server responded
if (!responseObject) {
log.WARN("Server did not respond to checkIn. Will wait 60 seconds then try again");
setTimeout(() => {
// Run itself again to see if the server is up now
this.checkIn();
}, 60000);
return
}
// Update the client's ID if the server accepted its
if (responseObject.statusCode === 202) {
runningClientConfig.id = responseObject.body.nodeId;
log.DEBUG("Response object from new node: ", responseObject, runningClientConfig);
await updateId(runningClientConfig.id);
}
if (responseObject.statusCode >= 300) {
// Server threw an error
log.DEBUG("HTTP Error: ", responseObject, await BufferToJson(responseObject.body));
await onHttpError(responseObject.statusCode);
}
});
}
else {
// ID is in the config, checking in with the server
reqOptions = new requests.requestOptions("/nodes/nodeCheckIn", "POST");
requests.sendHttpRequest(reqOptions, JSON.stringify(config.clientConfig), (responseObject) => {
if (update) reqOptions = new requestOptions(`/nodes/${runningClientConfig.id}`, "PUT");
else reqOptions = new requestOptions(`/nodes/nodeCheckIn/${runningClientConfig.id}`, "POST");
sendHttpRequest(reqOptions, JSON.stringify(runningClientConfig), (responseObject) => {
log.DEBUG("Check In Respose: ", responseObject);
// Check if the server responded
if (!responseObject) {
log.WARN("Server did not respond to checkIn. Will wait 60 seconds then try again");
setTimeout(() => {
// Run itself again to see if the server is up now
this.checkIn();
}, 60000);
return
}
if (responseObject.statusCode === 202) {
log.DEBUG("Updated keys: ", responseObject.body.updatedKeys)
// Server accepted an update
}
if (responseObject.statusCode === 200) {
// Server accepted the response but there were no keys to be updated
if (!update){
const tempUpdatedConfig = updateClientConfig(responseObject.body);
if (!tempUpdatedConfig.length > 0) return;
}
}
if (responseObject.statusCode >= 300) {
// Server threw an error
onHttpError(responseObject.statusCode);
}
});
}
}
catch (err) {
log.ERROR("Error checking in: ", err);
}
}
/** Controller for the /client/requestCheckIn endpoint
@@ -128,11 +181,30 @@ exports.requestCheckIn = async (req, res) => {
return res.sendStatus(200);
}
/**
* Express JS Wrapper for checking and updating client config
* @param {*} req
* @param {*} res
* @returns
*/
exports.updateClientConfigWrapper = async (req, res) => {
// Convert the online status to a boolean to be worked with
log.DEBUG("REQ Body: ", req.body);
const updatedKeys = await updateClientConfig(req.body);
if (updatedKeys) {
log.DEBUG("Keys have been updated, updating running config and checking in with the server: ", updatedKeys);
runningClientConfig = await getFullConfig();
await this.checkIn(true);
}
res.status(200).json(updatedKeys);
}
/** Controller for the /client/presets endpoint
* This is the endpoint wrapper to get the presets object
*/
exports.getPresets = async (req, res) => {
return res.status(200).json(updatePreset.getPresets());
runningClientConfig.nearbySystems = getPresets();
return res.status(200).json(runningClientConfig.nearbySystems);
}
/** Controller for the /client/updatePreset endpoint
@@ -140,9 +212,11 @@ exports.getPresets = async (req, res) => {
*/
exports.updatePreset = async (req, res) => {
checkBodyForPresetFields(req, res, () => {
updatePreset.updatePreset(req.body.systemName, () => {
updatePreset(req.body.systemName, () => {
runningClientConfig.nearbySystems = getPresets();
this.checkIn(true);
return res.sendStatus(200);
}, {frequencies: req.body.frequencies, mode: req.body.mode, trunkFile: req.body.trunkFile});
}, { frequencies: req.body.frequencies, mode: req.body.mode, trunkFile: req.body.trunkFile });
})
}
@@ -151,10 +225,32 @@ exports.updatePreset = async (req, res) => {
*/
exports.addNewPreset = async (req, res) => {
checkBodyForPresetFields(req, res, () => {
updatePreset.addNewPreset(req.body.systemName, req.body.frequencies, req.body.mode, () => {
addNewPreset(req.body.systemName, req.body.frequencies, req.body.mode, () => {
runningClientConfig.nearbySystems = getPresets();
this.checkIn(true);
return res.sendStatus(200);
}, req.body.trunkFile);
});
}
/**
* Removes a preset from the client
*/
exports.removePreset = async (req, res) => {
checkBodyForPresetFields(req, res, () => {
if (!req.body.systemName) return res.status("500").json({ "message": "You must specify a system name to delete, this must match exactly to how the system name is saved." })
removePreset(req.body.systemName, () => {
runningClientConfig.nearbySystems = getPresets();
this.checkIn(true);
return res.sendStatus(200);
}, req.body.trunkFile);
});
}
/**
* Runs the updater service
*/
exports.updateClient = async (req, res) => {
await executeAsyncConsoleCommand("systemctl start RadioNodeUpdater.service");
return res.sendStatus(200);
}

View File

@@ -1,163 +0,0 @@
require('dotenv').config();
// Debug
const { DebugBuilder } = require("../utilities/debugBuilder.js");
const log = new DebugBuilder("client", "commandController");
// Modules
const { joinVoiceChannel, VoiceConnectionStatus, getVoiceConnection, createAudioPlayer, createAudioResource, AudioPlayerStatus } = require("@discordjs/voice");
const { OpusEncoder } = require("@discordjs/opus");
const { calcRmsSync } = require("../utilities/rmsCalculator.js");
// Utilities
const {replyToInteraction} = require("../utilities/messageHandler.js");
const {createAudioInstance} = require("../controllers/audioController.js");
const { all } = require('../routes/index.js');
// Declare the encoder
const encoder = new OpusEncoder(48000, 2);
const noiseGateOpen = process.env.AUDIO_NOISE_GATE_OPEN ?? 100;
/**
* Join the specified voice channel
*
* @param interaction Message interaction from discord
* @param {string||any} guildID The specified Guild ID if this function is run from the client instead of from an interaction in Discord
* @param {string||any} channelID The channel ID to join
* @param guild The guild object to be used to create a voice adapter
* @param {function} callback The callback that will be needed if this function is run with a Guild ID instead of an interaction
*/
exports.join = async function join({interaction= undefined, guildID= undefined, channelID = undefined, guildObj = undefined, callback = undefined}){
if (interaction){
const voiceChannel = interaction.options.getChannel('voicechannel');
channelID = voiceChannel.id;
guildID = interaction.guildId;
guildObj = interaction.guild;
if (interaction) replyToInteraction(interaction, `Ok, Joining ${voiceChannel.name}`);
}
log.DEBUG("Channel ID: ", channelID)
log.DEBUG("Guild ID: ", guildID)
const vcConnectionObj = {
channelId: channelID,
guildId: guildID,
adapterCreator: guildObj.voiceAdapterCreator,
selfMute: false,
selfDeaf: false,
};
// Join the voice channel
const voiceConnection = await joinVoiceChannel(vcConnectionObj);
// Create the audio stream instance
const audioInstance = await createAudioInstance();
log.VERBOSE("Audio Instance: ", audioInstance);
// Play audio data when it's received from the stream
audioInstance.on('data', buffer => {
buffer = Buffer.from(buffer);
//log.VERBOSE("Audio buffer: ", buffer);
// Check intensity of audio and only play when audio is present (no white noise/static)
volume = Math.trunc(calcRmsSync(buffer, buffer.length));
if (parseInt(volume) > parseInt(noiseGateOpen)) {
//log.VERBOSE("Noisegate and buffer volume: ", (parseInt(volume) > parseInt(noiseGateOpen)), noiseGateOpen, volume);
const encodedBuffer = encoder.encode(buffer);
voiceConnection.playOpusPacket(encodedBuffer);
}
});
audioInstance.start();
// Handle state changes in the voice connection
voiceConnection.on('stateChange', async (oldState, newState) => {
//log.VERBOSE("VoiceConnection state Changed from state: ", oldState, "\n\nto state: ", newState);
log.DEBUG("VoiceConnection state Changed from: ", oldState.status, "to: ", newState.status);
// Ready -> Connecting
if (oldState.status === VoiceConnectionStatus.Ready && newState.status === VoiceConnectionStatus.Connecting) {
log.DEBUG("Configuring Network");
voiceConnection.configureNetworking();
return;
}
// Ready -> Disconnected
if (oldState.status === VoiceConnectionStatus.Ready && newState.status === VoiceConnectionStatus.Disconnected) {
log.DEBUG("Attempting to reconnect the voice connection");
voiceConnection = joinVoiceChannel(vcConnectionObj);
return;
}
// Ready
if (newState.status === VoiceConnectionStatus.Ready){
log.DEBUG("Bot connected to voice channel");
return;
}
// Destroyed
if (newState.status === VoiceConnectionStatus.Destroyed){
log.DEBUG("Quitting audio instance");
audioInstance.quit();
return;
}
});
voiceConnection.on('error', async (error) => {
log.ERROR("Voice Connection Error: ", error);
})
if (guildID && callback) return callback();
else return;
}
/**
* If in a voice channel for the specified guild, leave
*
* @param interaction Message interaction from discord
* @param guildID
* @param callback
*/
exports.leave = async function leave({interaction = undefined, guildID= undefined, callback = undefined}) {
if(interaction) {
guildID = interaction.guild.id;
}
const voiceConnection = getVoiceConnection(guildID);
let response;
if (!voiceConnection){
response = "Not in a voice channel."
if (interaction) return replyToInteraction(interaction, response);
else callback(response);
}
voiceConnection.destroy();
response = "Goodbye"
if (interaction) return replyToInteraction(interaction, response);
else callback(response);
}
/**
* Get the voice status of the bots
* @param {*} param0
* @returns
*/
exports.status = async function status({interaction= undefined, guildID= undefined, callback = undefined}) {
//if (!interaction && !guildID) // Need error of sorts
if (interaction){
guildID = interaction.guild.id;
}
const voiceConnection = getVoiceConnection(guildID);
const statusObj = {
"guildID": guildID,
"voiceConnection": typeof g !== 'undefined' ? true : false // True if there is a voice connection, false if undefined
}
log.DEBUG('Status Object: ', statusObj);
// get the status and return it accordingly (message reply / module)
if (interaction) {
return replyToInteraction(interaction, "Pong! I have Aids and now you do too!");
}
else {
callback(statusObj);
}
}

View File

@@ -2,146 +2,72 @@
const { DebugBuilder } = require("../utilities/debugBuilder.js");
const log = new DebugBuilder("client", "radioController");
// Modules
const { resolve, dirname } = require('path');
const fs = require('fs');
const radioConfig = require('../config/clientConfig').radioAppConfig;
const radioConfigHelper = require("../utilities/radioConfigHelper");
const presetWrappers = require("../utilities/updatePresets");
const spawn = require('child_process').spawn;
const converter = require("convert-units");
require('dotenv').config();
const { closeProcessWrapper, changeCurrentConfigWrapper, openRadioSessionWrapper } = require("../utilities/utilities");
let radioChildProcess, tempRes, radioConfigPath;
let radioChildProcess;
/**
* Closes the radio executable if it's in one
*/
exports.closeRadioSession = (req, res) => {
if (!radioChildProcess) return res.sendStatus(200)
tempRes = res;
radioChildProcess.kill();
radioChildProcess = undefined;
exports.closeRadioSession = async (req, res) => {
if (!radioChildProcess || !req.body.radioSession) return res.sendStatus(204);
if (radioChildProcess) radioChildProcess = await closeProcessWrapper(radioChildProcess);
if (req.body.radioSession) req.body.radioSession = await closeProcessWrapper(req.body.radioSession);
if (!radioChildProcess) return res.sendStatus(200);
}
/**
* Change the current 'cfg.json' file to the preset specified
* @param {string} presetName
*/
exports.changeCurrentConfig = (req, res) => {
// Check if the given config is saved
log.DEBUG("[/radio/changeCurrentConfig] - Checking if provided preset is in the config");
if (!checkIfPresetExists(req.body.presetName)) return res.status(500).JSON("No preset with given name found in config"); // No preset with the given name is in the config
exports.changeCurrentConfig = async (req, res) => {
const presetName = req.body.presetName;
if (!presetName) return res.status(500).json("You must include the preset name")
// Check if the current config is the same as the preset given
const currentConfig = readOP25Config();
if (currentConfig.channels && currentConfig.channels.name === req.body.presetName) {
log.DEBUG("[/radio/changeCurrentConfig] - Current config is the same as the preset given");
return res.sendStatus(202);
const updatedConfigObject = await changeCurrentConfigWrapper(presetName);
// No change was made to the config
if (!updatedConfigObject) return res.sendStatus(200);
// Error was encountered
if (typeof updatedConfigObject === "string") return res.status(500).json(updatedConfigObject);
// There was a change made to the config, reopening the radio session if it was open
if (radioChildProcess) {
log.DEBUG("Radio session open, restarting to accept the new config");
const radioSessionResult = await openRadioSessionWrapper(radioChildProcess, presetName);
// throw an error to the client if the wrapper ran into an error
if (typeof radioSessionResult === "string") return res.status(500).json(updatedConfigObject);
}
// Convert radioPreset to OP25 'cfg.json. file
log.DEBUG("[/radio/changeCurrentConfig] - Converting radioPreset to OP25 config");
const updatedConfigObject = convertRadioPresetsToOP25Config(req.body.presetName);
// Replace current JSON file with the updated file
writeOP25Config(updatedConfigObject, () => {
res.sendStatus(200);
})
return res.sendStatus(202);
}
/**
* Open a new OP25 process tuned to the specified system
*/
exports.openRadioSession = () => {
if (radioChildProcess) closeRadioSession();
radioChildProcess = spawn(getRadioBinPath());
exports.openRadioSession = async (req, res) => {
const presetName = req.body.presetName;
if(!presetName) return res.status(500).json({"message": "You must include the preset name to start the radio session with"})
radioChildProcess = await openRadioSessionWrapper(radioChildProcess, presetName);
// throw an error to the client if the wrapper ran into an error
if (typeof radioSessionResult === "string") return res.status(500).json(updatedConfigObject);
return res.sendStatus(200);
}
/**
* Get the location of the 'multi_rx.py' binary from the config
* Attach the radio session to the request to be used elsewhere
*
* @param {*} req
* @param {*} res
*/
function getRadioBinPath(){
return resolve(radioConfig.bin);
}
/**
* Write the given config to the JSON file in OP25 the bin dir
* @param config The full config to be written to the file
* @param {function} callback The function to be called when this wrapper completes
*/
function writeOP25Config(config, callback = undefined) {
log.DEBUG("Updating OP25 config with: ", config);
fs.writeFile(getRadioConfigPath(), JSON.stringify(config), (err) => {
// Error checking
if (err) {
log.ERROR(err);
throw err;
}
log.DEBUG("Write Complete");
if (callback) callback()
});
}
/**
* Get the current config file in use by OP25
* @returns {object|*} The parsed config object currently set in OP25
*/
function readOP25Config() {
const configPath = getRadioConfigPath();
log.DEBUG(`Reading from config path: '${configPath}'`);
return JSON.parse(fs.readFileSync(configPath));
}
/**
* Get the path of the config for the radio app (OP25) and set the global variable
*/
function getRadioConfigPath(){
let radioConfigDirPath = dirname(getRadioBinPath());
return resolve(`${radioConfigDirPath}/cfg.json`);
}
/**
* Check to see if the preset name exists in the config
* @param {string} presetName The system name as saved in the preset
* @returns {true||false}
*/
function checkIfPresetExists(presetName) {
const savedPresets = presetWrappers.getPresets();
if (!Object.keys(savedPresets).includes(presetName)) return false;
else return true;
}
/**
* Convert a radioPreset to OP25's cfg.json file
*/
function convertRadioPresetsToOP25Config(presetName){
const savedPresets = presetWrappers.getPresets();
let frequencyString = "";
for (const frequency of savedPresets[presetName].frequencies){
frequencyString += `${converter(frequency).from("Hz").to("MHz")},`
}
frequencyString = frequencyString.slice(0, -1);
let updatedOP25Config;
switch (savedPresets[presetName].mode){
case "p25":
updatedOP25Config = new radioConfigHelper.P25({
"systemName": presetName,
"controlChannelsString": frequencyString,
"tagsFile": savedPresets[presetName].trunkFile
});
break;
case "nbfm":
//code for nbfm here
updatedOP25Config = new radioConfigHelper.NBFM({
"frequency": frequencyString,
"systemName": presetName
});
break;
default:
throw new Error("Radio mode of selected preset not recognized");
}
log.DEBUG(updatedOP25Config);
return updatedOP25Config;
exports.attachRadioSessionToRequest = async (req, res, next) => {
req.body.radioSession = radioChildProcess;
next();
}

View File

@@ -1,150 +0,0 @@
//Config
import { getTOKEN, getGuildID, getApplicationID } from './utilities/configHandler.js';
// Commands
import ping from './commands/ping.js';
import join from './commands/join.js';
import leave from './commands/leave.js';
import status from './commands/status.js';
// Debug
import ModuleDebugBuilder from "./utilities/moduleDebugBuilder.js";
const log = new ModuleDebugBuilder("bot", "app");
// Modules
import { Client, GatewayIntentBits } from 'discord.js';
// Utilities
import registerCommands from './utilities/registerCommands.js';
/**
* Host Process Object Builder
*
* This constructor is used to easily construct responses to the host process
*/
class HPOB {
/**
* Build an object to be passed to the host process
* @param command The command to that was run ("Status", "Join", "Leave", "ChgPreSet")
* @param response The response from the command that was run
*/
constructor(command = "Status"||"Join"||"Leave"||"ChgPreSet", response) {
this.cmd = command;
this.msg = response;
}
}
// Create the Discord client
const client = new Client({
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.MessageContent,
GatewayIntentBits.GuildVoiceStates
]
});
/**
* When the parent process sends a message, this will interpret the message and act accordingly
*
* DRB IPC Message Structure:
* msg.cmd = The command keyword; Commands covered on the server side
* msg.params = An array containing the parameters for the command
*
*/
process.on('message', (msg) => {
log.DEBUG('IPC Message: ', msg);
const guildID = getGuilds()[0];
log.DEBUG("Guild Name: ", getGuildNameFromID(guildID));
switch (msg.cmd) {
// Check the status of the bot
case "Status":
log.INFO("Status command run from IPC");
status({guildID: guildID, callback: (statusObj) => {
log.DEBUG("Status Object string: ", statusObj);
if (!statusObj.voiceConnection) return process.send(new HPOB("Status", "VDISCONN"));
}});
break;
// Check the params for a server ID and if so join the server
case "Join":
log.INFO("Join command run from IPC");
join({guildID: guildID, guildObj: client.guilds.cache.get(guildID), channelID: msg.params.channelID, callback: () => {
process.send(new HPOB("Join", "AIDS"));
}})
break;
// Check to see if the bot is in a server and if so leave
case "Leave":
log.INFO("Leave command run from IPC");
leave({guildID: guildID, callback: (response) => {
process.send(new HPOB("Leave", response));
}});
break;
default:
// Command doesn't exist
log.INFO("Unknown command run from IPC");
break;
}
})
// When the client is connected and ready
client.on('ready', () =>{
log.INFO(`${client.user.tag} is ready`)
process.send({'msg': "INIT READY"});
});
/*
* Saved For later
client.on('messageCreate', (message) => {
log.DEBUG(`Message Sent by: ${message.author.tag}\n\t'${message.content}'`);
});
*/
// When a command is sent
client.on('interactionCreate', (interaction) => {
if (interaction.isChatInputCommand()){
switch (interaction.commandName) {
case "ping":
ping(interaction);
break;
case "join":
join({ interaction: interaction });
break;
case "leave":
leave({ interaction: interaction });
break;
case "status":
status({ interaction: interaction });
break;
default:
interaction.reply({ content: 'Command not found, try one that exists', fetchReply: true })
.then((message) => log.DEBUG(`Reply sent with content ${message.content}`))
.catch((err) => log.ERROR(err));
}
}
})
function loginBot(){
client.login(getTOKEN());
}
function getGuilds() {
return client.guilds.cache.map(guild => guild.id)
}
function getGuildNameFromID(guildID) {
return client.guilds.cache.map((guild) => {
if (guild.id === guildID) return guild.name;
})[0]
}
function main(){
registerCommands(() => {
loginBot();
});
}
main();
//module.exports = client;

File diff suppressed because it is too large Load Diff

View File

@@ -1,25 +0,0 @@
{
"name": "discord-bot",
"version": "1.0.0",
"description": "",
"main": "app.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"@discordjs/builders": "^1.4.0",
"@discordjs/opus": "^0.9.0",
"@discordjs/rest": "^1.4.0",
"@discordjs/voice": "^0.14.0",
"@mapbox/node-pre-gyp": "^1.0.10",
"debug": "^4.3.4",
"discord.js": "^14.7.1",
"node-gyp": "^9.3.0",
"libsodium-wrappers": "^0.7.10",
"alsa-capture": "0.3.0"
},
"type": "module"
}

View File

@@ -1,46 +0,0 @@
# Discord Radio Bot: Command & Control - Client: Discord Bot (Client)
---
Explanation here
## Requirements
---
Requirements here (not modules, that will be installed with npm)
## Installation
---
Notes here
### Installation here
```shell
```
## Configuration
---
Notes here
### Configuration here
```shell
```
## Usage
---
### Usage here
```javascript
```

View File

@@ -1,17 +0,0 @@
// Debug
import Debug from 'debug';
/**
* Create the different logging methods for a function
* Namespace template = ("[app]:[fileName]:['INFO', 'WARNING', 'DEBUG', 'ERROR']")
* @param {string} appName The name of the app to be used in the 'app' portion of the namespace
* @param {string} fileName The name of the file calling the builder to be used in the 'fileName' portion of the namespace
*/
export default class ModuleDebugBuilder {
constructor(appName, fileName) {
this.INFO = Debug(`${appName}:${fileName}:INFO`);
this.DEBUG = Debug(`${appName}:${fileName}:DEBUG`);
this.WARN = Debug(`${appName}:${fileName}:WARNING`);
this.ERROR = Debug(`${appName}:${fileName}:ERROR`);
}
}

View File

@@ -1,55 +0,0 @@
import {SlashCommandBuilder} from "@discordjs/builders";
import {REST} from "@discordjs/rest";
import {getApplicationID, getGuildID, getTOKEN} from "./configHandler.js";
import { Routes, ChannelType } from "discord.js";
// Debug
import ModuleDebugBuilder from "./moduleDebugBuilder.js";
const log = new ModuleDebugBuilder("bot", "registerCommands");
const pingCommand = new SlashCommandBuilder()
.setName("ping")
.setDescription("Confirm the bot is online")
.toJSON();
const joinCommand = new SlashCommandBuilder()
.setName('join')
.setDescription('Joins a voice channel')
.addChannelOption((option) => option
.setName('voicechannel')
.setDescription('The Channel to voiceController')
.setRequired(false)
.addChannelTypes(ChannelType.GuildVoice))
.toJSON();
const leaveCommand = new SlashCommandBuilder()
.setName("leave")
.setDescription("Leave current voice channel")
.toJSON();
const statusCommand = new SlashCommandBuilder()
.setName("status")
.setDescription("Returns if the bot is connected to a channel or not")
.toJSON();
export default async function registerCommands(callback){
const commands = [
pingCommand,
joinCommand,
leaveCommand,
statusCommand
];
try {
const rest = new REST({ version: '10' }).setToken(getTOKEN());
const clientID = getApplicationID();
const guildID = getGuildID();
await rest.put(Routes.applicationGuildCommands(clientID, guildID), {
body: commands,
});
log.DEBUG("Successfully registered the following commands: ", commands)
callback();
} catch (err) {
log.ERROR(err);
}
}

1992
Client/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -17,15 +17,9 @@
"express": "~4.16.1",
"http-errors": "~1.6.3",
"morgan": "~1.9.1",
"replace-in-file": "~6.3.5",
"replace-in-file": "~7.0.1",
"@discordjs/builders": "^1.4.0",
"@discordjs/opus": "^0.9.0",
"@discordjs/rest": "^1.4.0",
"@discordjs/voice": "^0.14.0",
"@mapbox/node-pre-gyp": "^1.0.10",
"discord.js": "^14.7.1",
"node-gyp": "^9.3.0",
"libsodium-wrappers": "^0.7.10",
"naudiodon": "^2.3.6"
"discord.js": "^14.7.1"
}
}

5
Client/pdab/.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
*venv/
*__pycache__/
*.html
*.exe
LICENSE

215
Client/pdab/NoiseGatev2.py Normal file
View File

@@ -0,0 +1,215 @@
import audioop
import logging
import math
import time
import pyaudio
import discord
import numpy
voice_connection = None
LOGGER = logging.getLogger("Discord_Radio_Bot.NoiseGateV2")
# noinspection PyUnresolvedReferences
class AudioStream:
def __init__(self, _channels: int = 2, _sample_rate: int = 48000, _frames_per_buffer: int = 1024,
_input_device_index: int = None, _output_device_index: int = None, _input: bool = True,
_output: bool = True, _init_on_startup: bool = True):
self.paInstance_kwargs = {
'format': pyaudio.paInt16,
'channels': _channels,
'rate': _sample_rate,
'input': _input,
'output': _output,
'frames_per_buffer': _frames_per_buffer
}
if _input_device_index:
if _input:
self.paInstance_kwargs['input_device_index'] = _input_device_index
else:
LOGGER.warning(f"[AudioStream.__init__]:\tInput was not enabled."
f" Reinitialize with '_input=True'")
if _output_device_index:
if _output:
self.paInstance_kwargs['output_device_index'] = _output_device_index
else:
LOGGER.warning(f"[AudioStream.__init__]:\tOutput was not enabled."
f" Reinitialize with '_output=True'")
if _init_on_startup:
# Init PyAudio instance
LOGGER.info("Creating PyAudio instance")
self.paInstance = pyaudio.PyAudio()
# Define and initialize stream object if we have been passed a device ID (pyaudio.open)
self.stream = None
if _output_device_index or _input_device_index:
if _init_on_startup:
LOGGER.info("Init stream")
self.init_stream()
def init_stream(self, _new_output_device_index: int = None, _new_input_device_index: int = None):
# Check what device was asked to be changed (or set)
if _new_input_device_index:
if self.paInstance_kwargs['input']:
self.paInstance_kwargs['input_device_index'] = _new_input_device_index
else:
LOGGER.warning(f"[AudioStream.init_stream]:\tInput was not enabled when initialized."
f" Reinitialize with '_input=True'")
if _new_output_device_index:
if self.paInstance_kwargs['output']:
self.paInstance_kwargs['output_device_index'] = _new_output_device_index
else:
LOGGER.warning(f"[AudioStream.init_stream]:\tOutput was not enabled when initialized."
f" Reinitialize with '_output=True'")
self.close_if_open()
# Open the stream
self.stream = self.paInstance.open(**self.paInstance_kwargs)
def close_if_open(self):
# Stop the stream if it is started
if self.stream:
if self.stream.is_active():
self.stream.stop_stream()
self.stream.close()
LOGGER.debug(f"[ReopenStream.close_if_open]:\t Stream was open; It was closed.")
def list_devices(self, _display_input_devices: bool = True, _display_output_devices: bool = True):
LOGGER.info('Getting a list of the devices connected')
info = self.paInstance.get_host_api_info_by_index(0)
numdevices = info.get('deviceCount')
devices = {
'Input': {},
'Output': {}
}
for i in range(0, numdevices):
if (self.paInstance.get_device_info_by_host_api_device_index(0, i).get('maxInputChannels')) > 0:
input_device = self.paInstance.get_device_info_by_host_api_device_index(0, i).get('name')
devices['Input'][i] = input_device
if _display_input_devices:
LOGGER.debug(f"Input Device id {i} - {input_device}")
if (self.paInstance.get_device_info_by_host_api_device_index(0, i).get('maxOutputChannels')) > 0:
output_device = self.paInstance.get_device_info_by_host_api_device_index(0, i).get('name')
devices['Output'][i] = output_device
if _display_output_devices:
LOGGER.debug(f"Output Device id {i} - {output_device}")
return devices
async def stop(self):
await voice_connection.disconnect()
self.close_if_open()
self.stream.close()
self.paInstance.terminate()
# noinspection PyUnresolvedReferences
class NoiseGate(AudioStream):
def __init__(self, _voice_connection, _noise_gate_threshold: int, **kwargs):
super(NoiseGate, self).__init__(_init_on_startup=True, **kwargs)
global voice_connection
voice_connection = _voice_connection
self.THRESHOLD = _noise_gate_threshold
self.NGStream = NoiseGateStream(self)
self.Voice_Connection_Thread = None
def run(self) -> None:
global voice_connection
# Start the audio stream
LOGGER.debug(f"Starting stream")
self.stream.start_stream()
# Start the stream to discord
self.core()
def core(self, error=None):
if error:
LOGGER.warning(error)
while not voice_connection.is_connected():
time.sleep(.2)
if not voice_connection.is_playing():
LOGGER.debug(f"Playing stream to discord")
voice_connection.play(self.NGStream, after=self.core)
async def close(self):
LOGGER.debug(f"Closing")
await voice_connection.disconnect()
if self.stream.is_active:
self.stream.stop_stream()
LOGGER.debug(f"Stopping stream")
# noinspection PyUnresolvedReferences
class NoiseGateStream(discord.AudioSource):
def __init__(self, _stream):
super(NoiseGateStream, self).__init__()
self.stream = _stream # The actual audio stream object
self.NG_fadeout = 240/20 # Fadeout value used to hold the noisegate after de-triggering
self.NG_fadeout_count = 0 # A count set when the noisegate is triggered and was de-triggered
self.process_set_count = 0 # Counts how many processes have been made
def read(self):
try:
while voice_connection.is_connected():
curr_buffer = bytearray(self.stream.stream.read(960))
buffer_rms = audioop.rms(curr_buffer, 2)
if buffer_rms > 0:
buffer_decibel = 20 * math.log10(buffer_rms)
if self.process_set_count % 10 == 0:
if buffer_decibel >= self.stream.THRESHOLD:
LOGGER.debug(f"[Noisegate Open] {buffer_decibel} db")
else:
LOGGER.debug(f"[Noisegate Closed] {buffer_decibel} db")
if buffer_decibel >= self.stream.THRESHOLD:
self.NG_fadeout_count = self.NG_fadeout
self.process_set_count += 1
if curr_buffer:
return bytes(curr_buffer)
else:
if self.NG_fadeout_count > 0:
self.NG_fadeout_count -= 1
LOGGER.debug(f"Frames in fadeout remaining: {self.NG_fadeout_count}")
self.process_set_count += 1
if curr_buffer:
return bytes(curr_buffer)
except OSError as e:
LOGGER.warning(e)
pass
def audio_datalist_set_volume(self, datalist, volume):
""" Change value of list of audio chunks """
sound_level = (volume / 100.)
for i in range(len(datalist)):
chunk = numpy.fromstring(datalist[i], numpy.int16)
chunk = chunk * sound_level
datalist[i] = chunk.astype(numpy.int16)
if __name__ == '__main__':
input_index = int(input("Input:\t"))
output_index = int(input("Output:\t"))
ng = NoiseGate(_input_device_index=input_index, _output_device_index=output_index)
ng.list_devices()
ng.start()

11
Client/pdab/getDevices.py Normal file
View File

@@ -0,0 +1,11 @@
from NoiseGatev2 import AudioStream
print('Getting a list of devices')
list_of_devices = AudioStream().list_devices()
print("----- INPUT DEVICES -----")
for inputDevice in list_of_devices['Input']:
print(f"{inputDevice}\t-\t{list_of_devices['Input'][inputDevice]}")
print("----- OUTPUT DEVICES -----")
for outputDevice in list_of_devices['Output']:
print(f"{outputDevice}\t-\t{list_of_devices['Output'][outputDevice]}")

81
Client/pdab/main.py Normal file
View File

@@ -0,0 +1,81 @@
import argparse, platform, os
from discord import Intents, Client, Member, opus, Activity, ActivityType
from discord.ext import commands
from NoiseGatev2 import NoiseGate
# Load the proper OPUS library for the device being used
async def load_opus():
# Check the system type and load the correct library
# Linux ARM AARCH64 running 32bit OS
processor = platform.machine()
script_dir = os.path.dirname(os.path.abspath(__file__))
print("Processor: ", processor)
if os.name == 'nt':
if processor == "AMD64":
opus.load_opus(os.path.join(script_dir, './opus/libopus_amd64.dll'))
print(f"Loaded OPUS library for AMD64")
return "AMD64"
else:
if processor == "aarch64":
opus.load_opus(os.path.join(script_dir, './opus/libopus_aarcch64.so'))
print(f"Loaded OPUS library for aarch64")
return "aarch64"
elif processor == "armv7l":
opus.load_opus(os.path.join(script_dir, './opus/libopus_armv7l.so'))
print(f"Loaded OPUS library for armv7l")
return "armv7l"
def main(clientId='OTQzNzQyMDQwMjU1MTE1MzA0.Yg3eRA.ZxEbRr55xahjfaUmPY8pmS-RHTY', channelId=367396189529833476, NGThreshold=50, deviceId=1, presence="the radio"):
intents = Intents.default()
client = commands.Bot(command_prefix='!', intents=intents)
@client.event
async def on_ready():
print(f'We have logged in as {client.user}')
# Set the presence of the bot (what it's listening to)
await client.change_presence(activity=Activity(type=ActivityType.listening, name=presence))
channelIdToJoin = client.get_channel(channelId)
print("Channel", channelIdToJoin)
print("Loading opus")
await load_opus()
if opus.is_loaded():
print("Joining voice")
channelConnection = await channelIdToJoin.connect(timeout=60.0, reconnect=True)
print("Voice Connected")
streamHandler = NoiseGate(
_input_device_index=deviceId,
_voice_connection=channelConnection,
_noise_gate_threshold=NGThreshold)
# Start the audio stream
streamHandler.run()
print("stream running")
client.run(clientId)
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("deviceId", type=int, help="The ID of the audio device to use")
parser.add_argument("channelId", type=int, help="The ID of the voice channel to use")
parser.add_argument("clientId", type=str, help="The discord client ID")
parser.add_argument("-n", "--NGThreshold", type=int, help="Change the noisegate threshold. This defaults to 50")
parser.add_argument("-p", "--presence", type=str, help="What the bot should be listening to")
args = parser.parse_args()
if (not args.NGThreshold):
args.NGThreshold = 50
print("Arguments:", args)
main(
clientId=args.clientId,
channelId=args.channelId,
NGThreshold=args.NGThreshold,
deviceId=args.deviceId,
presence=args.presence
)

Binary file not shown.

Binary file not shown.

Binary file not shown.

180
Client/pdab/recorder.py Normal file
View File

@@ -0,0 +1,180 @@
import pyaudio
import wave, logging, threading, time, queue, signal, argparse, audioop
from os import path, makedirs
logging.basicConfig(format="%(asctime)s: %(message)s", level=logging.INFO,datefmt="%H:%M:%S")
class DiscordRecorder:
def __init__(self, DEVICE_ID, CHUNK = 960, FORMAT = pyaudio.paInt16, CHANNELS = 2, RATE = 48000, FILENAME = "./recs/radio.wav"):
self.pa_instance = pyaudio.PyAudio()
self.DEVICE_ID = DEVICE_ID
self.CHUNK = CHUNK
self.FORMAT = FORMAT
self.CHANNELS = CHANNELS
self.RATE = RATE
self.NG_fadeout = 240/20 # Fadeout value used to hold the noisegate after de-triggering
self.NG_fadeout_count = 0 # A count set when the noisegate is triggered and was de-triggered
self.process_set_count = 0 # Counts how many processes have been made
self.FILENAME = FILENAME
self._check_file_path_exists()
self.queued_frames = queue.Queue()
self.stop_threads = threading.Event()
self.recording_thread = None
self.saving_thread = None
self.running_stream = None
# Wrapper to check if the given filepath (not file itself) exists
def _check_file_path_exists(self):
if not path.exists(path.dirname(self.FILENAME)):
makedirs(path.dirname(self.FILENAME), exist_ok=True)
# Wrapper for the recorder thread; Adds new data to the queue
def _recorder(self):
logging.info("* Recording Thread Starting")
while True:
try:
curr_buffer = bytearray(self.stream.stream.read(self.CHUNK))
buffer_rms = audioop.rms(curr_buffer, 2)
if buffer_rms > 0:
buffer_decibel = 20 * math.log10(buffer_rms)
if self.process_set_count % 10 == 0:
if buffer_decibel >= self.stream.THRESHOLD:
LOGGER.debug(f"[Noisegate Open] {buffer_decibel} db")
else:
LOGGER.debug(f"[Noisegate Closed] {buffer_decibel} db")
if buffer_decibel >= self.stream.THRESHOLD:
self.NG_fadeout_count = self.NG_fadeout
self.process_set_count += 1
if curr_buffer:
return self.queued_frames.put(curr_buffer)
else:
if self.NG_fadeout_count > 0:
self.NG_fadeout_count -= 1
LOGGER.debug(f"Frames in fadeout remaining: {self.NG_fadeout_count}")
self.process_set_count += 1
if curr_buffer:
return self.queued_frames.put(curr_buffer)
except OSError as e:
LOGGER.warning(e)
pass
# check for stop
if self.stop_threads.is_set():
break
# Wrapper for saver thread; Saves the queue to the file
def _saver(self):
logging.info("* Saving Thread Starting")
while True:
if not self.queued_frames.empty():
dequeued_frames = []
for i in range(self.queued_frames.qsize()):
dequeued_frames.append(self.queued_frames.get())
if not path.isfile(self.FILENAME):
wf = wave.open(self.FILENAME, 'wb')
wf.setnchannels(self.CHANNELS)
wf.setsampwidth(self.pa_instance.get_sample_size(self.FORMAT))
wf.setframerate(self.RATE)
wf.writeframes(b''.join(dequeued_frames))
wf.close()
else:
read_file = wave.open(self.FILENAME, 'rb')
read_file_data = read_file.readframes(read_file.getnframes())
read_file.close()
wf = wave.open(self.FILENAME, 'wb')
wf.setnchannels(self.CHANNELS)
wf.setsampwidth(self.pa_instance.get_sample_size(self.FORMAT))
wf.setframerate(self.RATE)
wf.writeframes(read_file_data)
wf.writeframes(b''.join(dequeued_frames))
wf.close()
# check for stop
if self.stop_threads.is_set():
break
time.sleep(5)
# Start the recording function
def start_recording(self):
logging.info("* Recording")
self.running_stream = self.pa_instance.open(
input_device_index=self.DEVICE_ID,
format=self.FORMAT,
channels=self.CHANNELS,
rate=self.RATE,
input=True,
frames_per_buffer=self.CHUNK
)
self.recording_thread = threading.Thread(target=self._recorder)
self.recording_thread.start()
self.saving_thread = threading.Thread(target=self._saver)
self.saving_thread.start()
# Stop the recording function
def stop_recording(self):
self.stop_threads.set()
self.recording_thread.join()
self.saving_thread.join()
self.running_stream.stop_stream()
self.running_stream.close()
self.pa_instance.terminate()
logging.info("* Done recording")
class GracefulExitCatcher:
def __init__(self, stop_callback):
self.stop = False
# The function to run when the exit signal is caught
self.stop_callback = stop_callback
# Update what happens when these signals are caught
signal.signal(signal.SIGINT, self.exit_gracefully)
signal.signal(signal.SIGTERM, self.exit_gracefully)
def exit_gracefully(self, *args):
logging.info("* Stop signal caught...")
# Stop the main loop
self.stop = True
# Run the given callback function
self.stop_callback()
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("deviceId", type=int, help="The ID of the audio device to use")
parser.add_argument("filename", type=str, help="The filepath/filename of the output file")
args = parser.parse_args()
logging.debug("Arguments:", args)
recorder = DiscordRecorder(args.deviceId, FILENAME=args.filename)
exit_catcher = GracefulExitCatcher(recorder.stop_recording)
recorder.start_recording()
while not exit_catcher.stop:
time.sleep(1)

View File

@@ -0,0 +1,5 @@
discord>=2.2.3
PyNaCl>=1.5.0
pyaudio>=0.2.13
numpy==1.24.3
argparse

View File

@@ -0,0 +1,183 @@
.node-card {
position: relative;
display: flex;
flex-direction: column;
min-width: 0;
word-wrap: break-word;
background-color: #fff;
background-clip: border-box;
border: 1px solid #eff0f2;
border-radius: 1rem;
margin-bottom: 24px;
box-shadow: 0 2px 3px #e4e8f0;
}
.avatar-md {
height: 4rem;
width: 4rem;
}
.rounded-circle {
border-radius: 50% !important;
}
.img-thumbnail {
padding: 0.25rem;
background-color: #f1f3f7;
border: 1px solid #eff0f2;
border-radius: 0.75rem;
}
.avatar-title {
align-items: center;
background-color: #3b76e1;
color: #fff;
display: flex;
font-weight: 500;
height: 100%;
justify-content: center;
width: 100%;
}
.bg-soft-primary {
background-color: rgba(59, 118, 225, .25) !important;
}
a {
text-decoration: none !important;
}
.badge-soft-danger {
color: #f56e6e !important;
background-color: rgba(245, 110, 110, .1);
}
.badge-soft-success {
color: #63ad6f !important;
background-color: rgba(99, 173, 111, .1);
}
.mb-0 {
margin-bottom: 0 !important;
}
.badge {
display: inline-block;
padding: 0.25em 0.6em;
font-size: 75%;
font-weight: 500;
line-height: 1;
color: #fff;
text-align: center;
white-space: nowrap;
vertical-align: baseline;
border-radius: 0.75rem;
}
/* Info Card Section */
.info-card {
background-color: #fff;
border-radius: 10px;
border: none;
position: relative;
margin-bottom: 30px;
box-shadow: 0 0.46875rem 2.1875rem rgba(90, 97, 105, 0.1), 0 0.9375rem 1.40625rem rgba(90, 97, 105, 0.1), 0 0.25rem 0.53125rem rgba(90, 97, 105, 0.12), 0 0.125rem 0.1875rem rgba(90, 97, 105, 0.1);
}
.info-card .card-statistic .card-icon-large .bi {
font-size: 110px;
}
.info-card .card-statistic .card-icon {
text-align: center;
line-height: 50px;
margin-left: 15px;
color: #000;
position: absolute;
right: -5px;
top: 20px;
opacity: 0.1;
}
/* Info Card Background Colors */
.l-bg-cherry {
background: linear-gradient(to right, #493240, #f09) !important;
color: #fff;
}
.l-bg-blue-dark {
background: linear-gradient(to right, #373b44, #4286f4) !important;
color: #fff;
}
.l-bg-green-dark {
background: linear-gradient(to right, #0a504a, #38ef7d) !important;
color: #fff;
}
.l-bg-orange-dark {
background: linear-gradient(to right, #a86008, #ffba56) !important;
color: #fff;
}
.l-bg-cyan {
background: linear-gradient(135deg, #289cf5, #84c0ec) !important;
color: #fff;
}
.l-bg-green {
background: linear-gradient(135deg, #23bdb8 0%, #43e794 100%) !important;
color: #fff;
}
.l-bg-orange {
background: linear-gradient(to right, #f9900e, #ffba56) !important;
color: #fff;
}
/* Global Section */
.sidebar-container {
min-height: 95vh;
}
.sidebar {
position: fixed;
top: 5vh;
bottom: 0;
left: 0;
}
/* User table section */
.label {
border-radius: 3px;
font-size: 1.1em;
font-weight: 600;
}
.user-list tbody td .user-subhead {
font-size: 1em;
font-style: italic;
}
.table thead tr th {
text-transform: uppercase;
font-size: 0.875em;
}
.table thead tr th {
border-bottom: 2px solid #e7ebee;
}
.table tbody tr td:first-child {
font-size: 1.125em;
font-weight: 300;
}
.table tbody tr td {
font-size: 0.875em;
vertical-align: middle;
border-top: 1px solid #e7ebee;
padding: 12px 8px;
}

View File

@@ -0,0 +1,402 @@
$(document).ready(async () => {
console.log("Loading stored notifications...");
await loadStoredToasts();
console.log("Showing stored notifications...");
await showStoredToasts();
});
/**
* Gets all toasts stored in local storage
*
* @returns {Object} Object of toasts in storage
*/
function getStoredToasts() {
if (localStorage.getItem("toasts")) {
const storedToasts = JSON.parse(localStorage.getItem("toasts"));
console.log("LOADED STORED TOASTS: ", storedToasts);
navbarUpdateNotificationBellCount(storedToasts);
return storedToasts;
}
else return false
}
/**
* Adds a toast to storage, will not allow duplicates
*
* @param {Date} time The date object from when the toast was created
* @param {*} message The message of the toast
*/
function addToastToStorage(time, message) {
var toasts = [{ 'time': time, 'message': message }]
var storedToasts = getStoredToasts();
console.log("Adding new notification to storage: ", toasts);
if (storedToasts) {
toasts = toasts.concat(storedToasts);
console.log("Combined new and stored notifications: ", toasts);
toasts = toasts.filter((value, index, self) =>
index === self.findIndex((t) => (
t.time === value.time && t.message === value.message
))
)
}
console.log("Deduped stored notifications: ", toasts);
localStorage.setItem("toasts", JSON.stringify(toasts));
navbarUpdateNotificationBellCount(toasts);
}
/**
* Removes a toast from the local storage
*
* @param {Date} time The date object from when the toast was created
* @param {*} message The message of the toast
*/
function removeToastFromStorage(time, message) {
const toastToRemove = { 'time': time, 'message': message }
console.log("Toast to remove: ", toastToRemove);
var toasts = getStoredToasts();
console.log("Stored toasts: ", toasts);
if (toasts.indexOf(toastToRemove)) toasts.splice(toasts.indexOf(toastToRemove) - 1, 1)
console.log("Toasts with selected toast removed: ", toasts);
localStorage.setItem("toasts", JSON.stringify(toasts));
navbarUpdateNotificationBellCount(toasts);
}
/**
* Shows all stored toasts
*/
function showStoredToasts() {
const storedToasts = getStoredToasts();
if (!storedToasts) return
console.log("Loaded stored notifications to show: ", storedToasts);
for (const toast of storedToasts) {
const toastId = `${toast.time}-toast`;
console.log("Showing stored toast: ", toast, toastId);
const toastElement = bootstrap.Toast.getOrCreateInstance(document.getElementById(toastId));
toastElement.show();
}
}
/**
* Loads all toasts stored in the local storage into the DOM of the webpage
*/
function loadStoredToasts() {
const storedToasts = getStoredToasts();
if (!storedToasts) return
console.log("Loaded stored notifications: ", storedToasts);
for (const toast of storedToasts) {
createToast(toast.message, { time: toast.time })
}
}
/**
* Will update the count of notifications on the bell icon in the navbar
*
* @param {Array} storedToasts An array of stored toasts to be counted and updated in the navbar
*/
function navbarUpdateNotificationBellCount(storedToasts) {
const notificationBellIcon = document.getElementById("navbar-notification-bell");
var notificationBellCount = document.getElementById("notification-bell-icon-count");
if (!notificationBellCount) {
notificationBellCount = document.createElement('span');
notificationBellCount.id = "notification-bell-icon-count";
notificationBellCount.classList.add('badge');
notificationBellCount.classList.add('text-bg-secondary');
notificationBellCount.appendChild(document.createTextNode(storedToasts.length));
}
else notificationBellCount.innerHTML = storedToasts.length;
notificationBellIcon.appendChild(notificationBellCount);
}
/**
* Remove a frequency input from the DOM
*
* @param {string} system The system name to add the frequency to
* @param {string} inputId [OPTIONAL] The ID of input, this can be anything unique to this input. If this is not provided the number of frequencies will be used as the ID
*/
function addFrequencyInput(system, inputId = null) {
if (!inputId) inputId = $(`[id^="${system}_systemFreqRow_"]`).length;
// Create new input
var icon = document.createElement('i');
icon.classList.add('bi');
icon.classList.add('bi-x-circle');
icon.classList.add('text-black');
var remove = document.createElement('a');
remove.classList.add('align-middle');
remove.classList.add('float-left');
remove.href = '#'
remove.onclick = () => { removeFrequencyInput(`${system}_systemFreqRow_${inputId}`) }
remove.appendChild(icon);
var childColRemoveIcon = document.createElement('div');
childColRemoveIcon.classList.add('col-2');
childColRemoveIcon.appendChild(remove);
var input = document.createElement('input');
input.classList.add('form-control');
input.id = `${system}_systemFreq_${inputId}`;
input.type = 'text';
var childColInput = document.createElement('div');
childColInput.classList.add('col-10');
childColInput.appendChild(input);
var childRow = document.createElement('div');
childRow.classList.add("row");
childRow.classList.add("px-1");
childRow.appendChild(childColInput);
childRow.appendChild(childColRemoveIcon);
var colParent = document.createElement('div');
colParent.classList.add("col-md-6");
colParent.classList.add("mb-1");
colParent.id = `${system}_systemFreqRow_${inputId}`
colParent.appendChild(childRow);
document.getElementById(`frequencyRow_${system.replaceAll(" ", "_")}`).appendChild(colParent);
}
/**
* Add a toast element to the DOM
*
* @param {*} notificationMessage The message of the notification
* @param {Date} param1.time The date object for when the toast was created, blank if creating new
* @param {boolean} param1.showNow Show the toast now or just store it
* @returns
*/
function createToast(notificationMessage, { time = undefined, showNow = false } = {}) {
if (!time) time = new Date(Date.now());
else time = new Date(Date.parse(time));
const toastTitle = document.createElement('strong');
toastTitle.classList.add('me-auto');
toastTitle.appendChild(document.createTextNode("Server Notification"));
const toastTime = document.createElement('small');
toastTime.appendChild(document.createTextNode(time.toLocaleString()));
const toastClose = document.createElement('button');
toastClose.type = 'button';
toastClose.classList.add('btn-close');
toastClose.ariaLabel = 'Close';
toastClose.setAttribute('data-bs-dismiss', 'toast');
toastClose.onclick = () => { removeToastFromStorage(time.toISOString(), notificationMessage); };
const toastHeader = document.createElement('div');
toastHeader.classList.add('toast-header');
toastHeader.appendChild(toastTitle);
toastHeader.appendChild(toastTime);
toastHeader.appendChild(toastClose);
const toastMessage = document.createElement('p');
toastMessage.classList.add("px-2");
toastMessage.appendChild(document.createTextNode(notificationMessage));
const toastBody = document.createElement('div');
toastBody.classList.add('toast-body');
toastBody.appendChild(toastMessage);
const wrapperDiv = document.createElement('div');
wrapperDiv.classList.add('toast');
//wrapperDiv.classList.add('position-fixed');
wrapperDiv.id = `${time.toISOString()}-toast`;
wrapperDiv.role = 'alert';
wrapperDiv.ariaLive = 'assertive';
wrapperDiv.ariaAtomic = true;
wrapperDiv.setAttribute('data-bs-delay', "7500");
wrapperDiv.setAttribute('data-bs-animation', true);
wrapperDiv.appendChild(toastHeader);
wrapperDiv.appendChild(toastMessage);
document.getElementById("toastZone").appendChild(wrapperDiv);
addToastToStorage(time.toISOString(), notificationMessage);
if (showNow) {
const toastElement = bootstrap.Toast.getOrCreateInstance(document.getElementById(`${time.toISOString()}-toast`));
toastElement.show();
}
return;
}
function sendNodeHeartbeat(nodeId) {
const Http = new XMLHttpRequest();
const url = '/client/requestCheckIn' + nodeId;
Http.open("GET", url);
Http.send();
Http.onloadend = (e) => {
console.log(Http.responseText)
createToast(Http.responseText, { showNow: true });
}
}
function joinServer() {
const preset = document.getElementById("selectRadioPreset").value;
const clientId = document.getElementById("inputDiscordClientId").value;
const channelId = document.getElementById("inputDiscordChannelId").value;
const reqBody = {
'preset': preset,
'clientId': clientId,
'channelId': channelId
};
console.log(reqBody);
const Http = new XMLHttpRequest();
const url = '/bot/join';
Http.open("POST", url);
Http.setRequestHeader("Content-Type", "application/json");
Http.send(JSON.stringify(reqBody));
Http.onloadend = (e) => {
const responseObject = JSON.parse(Http.responseText)
console.log(Http.status);
console.log(responseObject);
createToast(`${responseObject.name} will join shortly`);
location.reload();
}
}
function leaveServer() {
const reqBody = {};
const Http = new XMLHttpRequest();
const url = '/bot/leave';
Http.open("POST", url);
Http.setRequestHeader("Content-Type", "application/json");
Http.send(JSON.stringify(reqBody));
Http.onloadend = (e) => {
const responseObject = Http.responseText;
console.log(Http.status);
console.log(responseObject);
createToast(`${responseObject} is leaving`, { showNow: true });
}
}
function saveNodeDetails() {
const nodeName = document.getElementById("inputNodeName").value;
const nodeIp = document.getElementById("inputNodeIp").value;
const nodePort = document.getElementById("inputOrgName").value;
const nodeLocation = document.getElementById("inputNodeLocation").value;
const reqBody = {
'name': nodeName,
'ip': nodeIp,
'port': nodePort,
'location': nodeLocation
}
console.log("Request Body: ", reqBody);
const Http = new XMLHttpRequest();
const url = '/client';
Http.open("PUT", url);
Http.setRequestHeader("Content-Type", "application/json");
Http.send(JSON.stringify(reqBody));
Http.onloadend = (e) => {
const responseObject = JSON.parse(Http.responseText);
console.log(Http.status);
console.log(responseObject);
createToast(`Node Updated!`);
location.reload();
}
}
function addNewSystem() {
const nodeId = document.getElementById("nodeId").value;
const systemName = document.getElementById(`New System_systemName`).value;
const systemMode = document.getElementById(`New System_systemMode`).value;
const inputSystemFreqs = $(`[id^="New System_systemFreq_"]`);
let systemFreqs = [];
for (const inputFreq of inputSystemFreqs) {
systemFreqs.push(inputFreq.value);
}
const reqBody = {
'systemName': systemName,
'mode': systemMode,
'frequencies': systemFreqs
}
if (reqBody.mode == "p25") reqBody.trunkFile = 'none';
console.log("Request Body: ", reqBody);
const Http = new XMLHttpRequest();
const url = "/client/addPreset";
Http.open("POST", url);
Http.setRequestHeader("Content-Type", "application/json");
Http.send(JSON.stringify(reqBody));
Http.onloadend = (e) => {
const responseObject = Http.responseText
console.log(Http.status);
console.log(responseObject);
createToast(`${systemName} Added!`);
location.reload();
}
}
function updateSystem(systemName) {
const nodeId = document.getElementById("nodeId").value;
const systemMode = document.getElementById(`${systemName}_systemMode`).value;
const inputSystemFreqs = $(`[id^="${systemName}_systemFreq_"]`);
let systemFreqs = [];
for (const inputFreq of inputSystemFreqs) {
systemFreqs.push(inputFreq.value);
}
const reqBody = {
'systemName': systemName,
'mode': systemMode,
'frequencies': systemFreqs
}
if (reqBody.mode == "p25") reqBody.trunkFile = 'none';
console.log("Request Body: ", reqBody);
const Http = new XMLHttpRequest();
const url = "/client/updatePreset";
Http.open("POST", url);
Http.setRequestHeader("Content-Type", "application/json");
Http.send(JSON.stringify(reqBody));
Http.onloadend = (e) => {
const responseObject = Http.responseText;
console.log(Http.status);
console.log(responseObject);
createToast(`${systemName} Updated!`);
location.reload();
}
}
function removeSystem(systemName) {
const nodeId = document.getElementById("nodeId").value;
const reqBody = {
'systemName': systemName,
}
console.log("Request Body: ", reqBody);
const Http = new XMLHttpRequest();
const url = '/client/removePreset';
Http.open("POST", url);
Http.setRequestHeader("Content-Type", "application/json");
Http.send(JSON.stringify(reqBody));
Http.onloadend = (e) => {
const responseObject = Http.responseText;
console.log(Http.status);
console.log(responseObject);
createToast(`${systemName} Removed!`);
location.reload();
}
}
function requestNodeUpdate() {
}
function removeFrequencyInput(elementId) {
const element = document.getElementById(elementId);
element.remove();
}

View File

@@ -1,8 +0,0 @@
body {
padding: 50px;
font: 14px "Lucida Grande", Helvetica, Arial, sans-serif;
}
a {
color: #00B7FF;
}

View File

@@ -2,13 +2,26 @@
---
Explanation here
The client application communicates with the server through the provided API. Each client instance waits for join requests sent by users through Discord. Once a join request is received, the client uses the SDR application to tune into the specified radio preset. It then establishes a connection to Discord, allowing users to listen to the selected radio preset in real-time.
In addition to its interaction with the server, the client also has its own API and web application. This enables users to directly interface with the client, perform actions specific to the client application, and access relevant information about the connected SDR and radio presets.
## Requirements
---
### Hardware
- SBC
- [Orange Pi](https://www.amazon.com/dp/B0BN16ZLXB/r)
- [Raspberry Pi](https://www.canakit.com/raspberry-pi-4-4gb.html)
- [Rock Pi](https://www.okdo.com/us/p/okdo-rock-4-model-c-4gb-single-board-computer-rockchip-rk3399-t-arm-cortex-a72-cortex-a53/)
- SDR
- [Nooelec RTL-SDR v5 Bundle ](https://www.amazon.com/dp/B01GDN1T4S)
- [RTL-SDR Blog V3](https://www.amazon.com/dp/B0BMKB3L47)
- [Nooelec NESDR Mini](https://www.amazon.com/dp/B009U7WZCA)
- Proper Power Adapter (Sometimes comes in SBC Packs)
- SD Card (Sometimes comes in SBC Packs)
Requirements here (not modules, that will be installed with npm)

View File

@@ -0,0 +1,7 @@
#!/bin/bash
# This script should be another service on the machine to watch the main script for failures and restart it if there are any
( tail -f -n0 /opt/sdr-scanner/scanner_log & ) | grep -q ": cb transfer status: 1, canceling..."
systemctl restart radioNode.service
echo "Restarted SDR Scanner service"

View File

@@ -5,7 +5,7 @@ const botController = require("../controllers/botController");
/** GET bot status
* Check to see if the bot is online and if so, if it is currently connected to anything
*
* The status of the bot: 200 = client is online but not connected to discord, 201 = online on discord, 202 = connected to a channel, 500 + JSON = encountered error
* The status of the bot: 200 = connected to discord, 201 = not connected to discord, 500 + JSON = encountered error
* @returns status
*/
router.get('/status', botController.getStatus);
@@ -15,7 +15,9 @@ router.get('/status', botController.getStatus);
*
* @param req The request sent from the master
* @param req.body.channelId The channel ID to join
* @param req.body.clientId The discord Client ID to use when connecting to the server
* @param req.body.presetName The name of the preset to start listening to
* @param req.body.NGThreshold [OPTIONAL] The noisegate threshold, this will default to 50
*/
router.post('/join', botController.joinServer);
@@ -27,4 +29,20 @@ router.post('/join', botController.joinServer);
*/
router.post('/leave', botController.leaveServer);
/** POST bot start recording
* If the bot is in a channel, it will start to record what it hears
*
* The status of the bot: 200 = starting to record, 202 = already recording, 204 = not in a server, 500 + JSON = encountered error
* @returns status
*/
router.post('/startRecording', botController.startRecording);
/** POST bot stop recording
* If the bot is recording, it will stop recording
*
* The status of the bot: 200 = will stop the recording, 204 = not currently recording, 500 + JSON = encountered error
* @returns status
*/
router.post('/stopRecording', botController.stopRecording);
module.exports = router;

View File

@@ -2,22 +2,31 @@
const express = require('express');
const router = express.Router();
// Controllers
const clientController = require("../controllers/clientController");
const { requestCheckIn, getPresets, updatePreset, addNewPreset, removePreset, updateClient, updateClientConfigWrapper } = require("../controllers/clientController");
/** GET Request a check in from the client
* Queue the client to check in with the server
*
* The status of the checkin request: 200 = Queued
*/
router.get('/requestCheckIn', clientController.requestCheckIn);
router.get('/requestCheckIn', requestCheckIn);
/** GET Object of all known presets
* Query the client to get all the known presets
*/
router.get('/presets', clientController.getPresets);
router.get('/presets', getPresets);
/**
* PUT An update to the running client config (not radio config)
* @param {number} req.body.id The ID given to the node to update
* @param {string} req.body.name The name of the node
* @param {string} req.body.ip The IP the server can contact the node on
* @param {number} req.body.port The port the server can contact the node on
* @param {string} req.body.location The physical location of the node
*/
router.put('/', updateClientConfigWrapper);
/** POST Update to preset
* Join the channel specified listening to the specified freq/mode
*
* @param req The request sent from the master
* @param {string} req.body.systemName The name of the system to be updated
@@ -25,7 +34,7 @@ router.get('/presets', clientController.getPresets);
* @param {string} req.body.mode The listening mode for the SDR
* @param {string} req.body.trunkFile If the listening mode is digital this can be set to identify the communications
*/
router.post('/updatePreset', clientController.updatePreset);
router.post('/updatePreset', updatePreset);
/** POST Add new preset
* Join the channel specified listening to the specified freq/mode
@@ -36,6 +45,20 @@ router.post('/updatePreset', clientController.updatePreset);
* @param {string} req.body.mode The listening mode for the SDR
* @param {string} req.body.trunkFile If the listening mode is digital this can be set to identify the communications
*/
router.post('/addPreset', clientController.addNewPreset);
router.post('/addPreset', addNewPreset);
/** POST Remove a preset
*
* @param req The request sent from the master
* @param {string} req.body.systemName The name of the system to be updated
*/
router.post('/removePreset', removePreset);
/** POST Update the bot
*
* @param req The request sent from the master
*/
router.post('/updateClient', updateClient);
module.exports = router;

View File

@@ -1,9 +1,11 @@
var express = require('express');
var router = express.Router();
const { getFullConfig } = require('../utilities/configHandler');
/* GET home page. */
router.get('/', function(req, res, next) {
res.render('index', { title: 'Express' });
router.get('/', async function(req, res, next) {
const clientConfig = await getFullConfig();
res.render('index', { 'node': clientConfig });
});
module.exports = router;

View File

@@ -15,6 +15,7 @@ router.post('/start', radioController.openRadioSession);
/**
* POST Close the current radio session
* Response from the radio: 200: closed; 204: not connected
*/
router.post('/stop', radioController.closeRadioSession);

View File

@@ -6,10 +6,80 @@ if [ "$EUID" -ne 0 ]
exit
fi
# Check for updates
apt-get update
# Prompt the user for reboot confirmation
read -p "This script will install all required components for the DRB client. Are you okay with rebooting afterward? If not, you will have to reboot later before running the applications to finish the installation. (Reboot?: y/n): " confirm
# Convert user input to lowercase for case-insensitive comparison
confirm="${confirm,,}"
if [[ "$confirm" != "y" && "$confirm" != "yes" ]]; then
echo "Script will not reboot."
should_reboot=false
else
echo "Script will reboot after completion."
should_reboot=true
fi
echo "----- Starting Radio Node Client Install Script -----"
SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
# Copy the example env file
cp .env.example .env
# Copy the radio config example file
cp config/radioPresets.json.EXAMPLE config/radioPresets.json
echo "----- Collecting Setup Information -----"
# Ask the user for input and store in variables
echo " \\Client Config"
read -p " Enter Node Name: " nodeName
read -p " Enter Node IP: " nodeIP
read -p " Enter Node Port: " nodePort
read -p " Enter Node Location: " nodeLocation
read -p " Enter Audio Device ID: " audioDeviceID
echo " \\Server Config"
read -p " Enter Server IP (leave blank if using hostname): " serverIP
if [ -z "$serverIP" ]; then
read -p " Enter Server Hostname: " serverHostname
fi
read -p " Enter Server Port: " serverPort
# Update the values in the env file using sed
sed -i "s/^AUDIO_DEVICE_ID=\".*\"$/AUDIO_DEVICE_ID=\"$audioDeviceID\"/" .env
sed -i "s/^CLIENT_NAME=\".*\"$/CLIENT_NAME=\"$nodeName\"/" .env
sed -i "s/^CLIENT_IP=\".*\"$/CLIENT_IP=\"$nodeIP\"/" .env
sed -i "s/^CLIENT_PORT=.*$/CLIENT_PORT=$nodePort/" .env
sed -i "s/^CLIENT_LOCATION=\".*\"$/CLIENT_LOCATION=\"$nodeLocation\"/" .env
if [ -z "$serverIP" ]; then
sed -i "s/^SERVER_HOSTNAME=\".*\"$/SERVER_HOSTNAME=\"$serverHostname\"/" .env
else
sed -i "s/^SERVER_IP=\".*\"$/SERVER_IP=\"$serverIP\"/" .env
fi
sed -i "s/^SERVER_PORT=\".*\"$/SERVER_PORT=\"$serverPort\"/" .env
echo "----- Config file has been updated -----"
# Display the updated values
echo "----- Start of Config File -----"
cat .env
echo "----- End of Config File -----"
echo "----- Getting Dependencies -----"
# Install Node Repo
# Get the CPU architecture
cpu_arch=$(uname -m)
# Print the CPU architecture for verification
echo "Detected CPU Architecture: $cpu_arch"
# Check if the architecture is ARMv6
if [[ "$cpu_arch" == "armv6"* ]]; then
echo "----- CPU Architecture is ARMv6 or compatible. -----"
echo "----- CPU Architectre is not compatible with dependencies of this project, please use a newer CPU architecture -----"
exit
curl -fsSL https://deb.nodesource.com/setup_current.x | sudo -E bash -
# Update the system
@@ -17,10 +87,122 @@ apt-get update
apt-get upgrade -y
# Install the necessary packages
apt-get install -y nodejs npm libopus-dev gcc make alsa-utils libasound2 libasound2-dev libpulse-dev pulseaudio apulse
# Ensure pulse audio is running
pulseaudio
apt-get install -y nodejs portaudio19-dev libportaudio2 libpulse-dev pulseaudio apulse python3 python3-pip git
# Install the node packages from the project
npm i
# Install the python packages needed for the bot
pip install -r ./pdab/requirements.txt
echo "----- Setting up Pulse Audio -----"
# Ensure pulse audio is running as system so the service can see the audio device
systemctl --global disable pulseaudio.service pulseaudio.socket
# Update the PulseAudio config to disable autospawning
sed -i 's/autospawn = .*$/autospawn = no/' /etc/pulse/client.conf
# Add the system PulseAudio service
echo "[Unit]
Description=PulseAudio system server
[Service]
Type=notify
ExecStart=pulseaudio --daemonize=no --system --realtime --log-target=journal
[Install]
WantedBy=multi-user.target" >> /etc/systemd/system/PulseAudio.service
# Add the root user to the pulse-access group
usermod -aG pulse-access root
usermod -aG pulse-access pi
# Enable the PulseAudio service
systemctl enable PulseAudio.service
echo "----- Setting up Radio Node Service -----"
# Setup bot service
echo "[Unit]
Description=Radio Node Service
After=network.target
[Service]
WorkingDirectory=$SCRIPT_DIR/
ExecStart=/usr/bin/node .
Restart=always
RestartDelay=10
Environment=\"DEBUG='client:*'\"
[Install]
WantedBy=multi-user.target" >> /etc/systemd/system/RadioNode.service
# Enable the Radio Node service
systemctl enable RadioNode.service
echo "----- Setting up Radio Node Update Service -----"
# Setup bot update service
echo "[Unit]
Description=Radio Node Updater Service
After=network.target
[Service]
WorkingDirectory=$SCRIPT_DIR/
ExecStart=/usr/bin/bash update.sh
Restart=on-failure
[Install]
WantedBy=multi-user.target" >> /etc/systemd/system/RadioNodeUpdater.service
# Install OP25
echo "----- Installing OP25 from Source -----"
# Clone the OP25 Git
cd /opt/
git clone https://github.com/boatbod/op25.git
cd op25
# Run the OP25 install script
bash ./install.sh
# Create the config file for the client or user to update later
cp /opt/op25/op25/gr-op25_repeater/apps/p25_rtl_example.json /opt/op25/op25/gr-op25_repeater/apps/radioNodeOP25Config.json
# Create the OP25 service
echo "[Unit]
Description=OP25 Service
After=network.target
[Service]
WorkingDirectory=/opt/op25/op25/gr-op25_repeater/apps
ExecStart=./multi_rx.py -c radioNodeOP25Config.json
Restart=always
[Install]
WantedBy=multi-user.target" >> /etc/systemd/system/OP25.service
echo "----- OP25 Setup Complete -----"
# Enable the OP25 service, don't start it though as the user needs to config
systemctl enable OP25.service
echo "----- OP25 Enabled; Please ensure to update the configuration and start the service -----"
# Move back to the directory that the user started in (might not be needed?)
cd $SCRIPT_DIR
echo "----- Setup Complete! -----"
# Reboot if the user confirmed earlier
if [ "$should_reboot" = true ]; then
echo "To configure the app, please go to http://$nodeIP:$nodePort"
echo "Thank you for joining the network!"
# Prompt user to press any key before rebooting
read -rsp $'System will now reboot, press any key to continue or Ctrl+C to cancel...\n' -n1 key
echo "Rebooting..."
reboot
else
echo "To configure the app, please go to http://$nodeIP:$nodePort"
echo "Thank you for joining the network!"
echo "Please restart your device to complete the installation"
fi

View File

@@ -1,15 +1,34 @@
#!/bin/bash
# Check if the user is root
if [ "$EUID" -ne 0 ]
then echo "Please run as root"
exit
fi
# Sleeping to give the client time to respond to the requester
sleep 5
# Stating Message
echo "<!-- UPDATING ---!>"
# TODO - Add an updater for Stable Diffusion API
# Update the git Repo
git fetch -a -p
git pull
# Stop any running service
systemctl stop RadioNode
# Get the owner of the current working directory
cwd_owner=$(stat -c '%U' .)
# Update the git Repo as the owner of the current working directory
sudo su -l $cwd_owner -c 'git fetch'
sudo su -l $cwd_owner -c 'git pull'
# Install any new libraries
npm i
# Start the service
systemctl start RadioNode
# Update complete message
echo "<!--- UPDATE COMPLETE! ---!>"

View File

@@ -2,6 +2,8 @@
const { DebugBuilder } = require("../utilities/debugBuilder.js");
const log = new DebugBuilder("client", "configController");
// Modules
const { nodeObject } = require("./utilities.js");
const { getPresets } = require("../utilities/updatePresets");
const { readFileSync } = require('fs');
const path = require("path");
require('dotenv').config();
@@ -34,3 +36,7 @@ function getDeviceName(){
return DeviceName;
}
exports.getDeviceName = getDeviceID;
exports.getFullConfig = () => {
return new nodeObject({_id: process.env.CLIENT_ID, _ip: process.env.CLIENT_IP, _name: process.env.CLIENT_NAME, _port: process.env.CLIENT_PORT, _location: process.env.CLIENT_LOCATION, _nearbySystems: getPresets()});
}

View File

@@ -4,6 +4,7 @@ const debug = require('debug');
require('dotenv').config();
// Modules
const { writeFile } = require('fs');
const { inspect } = require('util');
const logLocation = process.env.LOG_LOCATION;
@@ -34,31 +35,31 @@ exports.DebugBuilder = class DebugBuilder {
this.INFO = (...messageParts) => {
const _info = debug(`${appName}:${fileName}:INFO`);
_info(messageParts);
writeToLog(`${appName}:${fileName}:INFO\t-\t${messageParts.map((messagePart, index, array) => {return JSON.stringify(messagePart)})}`, appName);
writeToLog(`${Date.now().toLocaleString('en-US', { timeZone: 'America/New_York' })} - ${appName}:${fileName}:INFO\t-\t${messageParts.map((messagePart, index, array) => {return inspect(messagePart)})}`, appName);
}
this.DEBUG = (...messageParts) => {
const _debug = debug(`${appName}:${fileName}:DEBUG`);
_debug(messageParts);
writeToLog(`${appName}:${fileName}:DEBUG\t-\t${messageParts.map((messagePart, index, array) => {return JSON.stringify(messagePart)})}`, appName);
writeToLog(`${Date.now().toLocaleString('en-US', { timeZone: 'America/New_York' })} - ${appName}:${fileName}:DEBUG\t-\t${messageParts.map((messagePart, index, array) => {return inspect(messagePart)})}`, appName);
}
this.VERBOSE = (...messageParts) => {
const _verbose = debug(`${appName}:${fileName}:VERBOSE`);
_verbose(messageParts);
writeToLog(`${appName}:${fileName}:VERBOSE\t-\t${messageParts.map((messagePart, index, array) => {return JSON.stringify(messagePart)})}`, appName);
writeToLog(`${Date.now().toLocaleString('en-US', { timeZone: 'America/New_York' })} - ${appName}:${fileName}:VERBOSE\t-\t${messageParts.map((messagePart, index, array) => {return inspect(messagePart)})}`, appName);
}
this.WARN = (...messageParts) => {
const _warn = debug(`${appName}:${fileName}:WARNING`);
_warn(messageParts);
writeToLog(`${appName}:${fileName}:WARNING\t-\t${messageParts.map((messagePart, index, array) => {return JSON.stringify(messagePart)})}`, appName);
writeToLog(`${Date.now().toLocaleString('en-US', { timeZone: 'America/New_York' })} - ${appName}:${fileName}:WARNING\t-\t${messageParts.map((messagePart, index, array) => {return inspect(messagePart)})}`, appName);
}
this.ERROR = (...messageParts) => {
const _error = debug(`${appName}:${fileName}:ERROR`);
_error(messageParts);
writeToLog(`${appName}:${fileName}:ERROR\t-\t${messageParts.map((messagePart, index, array) => {return JSON.stringify(messagePart)})}`, appName);
writeToLog(`${Date.now().toLocaleString('en-US', { timeZone: 'America/New_York' })} - ${appName}:${fileName}:ERROR\t-\t${messageParts.map((messagePart, index, array) => {return inspect(messagePart)})}`, appName);
if (process.env.EXIT_ON_ERROR && process.env.EXIT_ON_ERROR > 0) {
writeToLog("!--- EXITING ---!", appName);
setTimeout(process.exit, process.env.EXIT_ON_ERROR_DELAY ?? 0);

View File

@@ -1,49 +0,0 @@
const { REST, Routes } = require('discord.js');
require('dotenv').config();
const token = process.env.TOKEN;
//const clientId = process.env.clientId;
//const guildId = process.env.guildId;
const fs = require('node:fs');
const path = require('node:path');
const { DebugBuilder } = require("./debugBuilder");
const log = new DebugBuilder("client", "deployCommands");
const commands = [];
// Grab all the command files from the commands directory you created earlier
const commandsPath = path.resolve(__dirname, '../commands');
const commandFiles = fs.readdirSync(commandsPath).filter(file => file.endsWith('.js'));
exports.deploy = (clientId, guildIDs) => {
log.DEBUG("Deploying commands for: ", guildIDs);
if (Array.isArray(guildIDs)) guildIDs = [guildIDs];
// Grab the SlashCommandBuilder#toJSON() output of each command's data for deployment
for (const file of commandFiles) {
const command = require(`${path.resolve(commandsPath, file)}`);
commands.push(command.data.toJSON());
}
// Construct and prepare an instance of the REST module
const rest = new REST({ version: '10' }).setToken(token);
// and deploy your commands!
for (const guildId of guildIDs){
(async () => {
try {
log.DEBUG(`Started refreshing ${commands.length} application (/) commands for guild ID: ${guildId}.`);
// The put method is used to fully refresh all commands in the guild with the current set
const data = await rest.put(
Routes.applicationGuildCommands(clientId, guildId),
{ body: commands },
);
log.DEBUG(`Successfully reloaded ${data.length} application (/) commands for guild ID: ${guildId}.`);
} catch (error) {
// And of course, make sure you catch and log any errors!
log.ERROR("ERROR Deploying commands: ", error, "Body from error: ", commands);
}
})()
}
};

View File

@@ -1,52 +0,0 @@
// Modules
const { promisify } = require('util');
const { exec } = require("child_process");
// Debug
const { DebugBuilder } = require("../utilities/debugBuilder.js");
// Global Vars
const log = new DebugBuilder("client", "executeConsoleCommands");
const execCommand = promisify(exec);
async function executeAsyncConsoleCommand(consoleCommand) {
// Check to see if the command is a real command
// TODO needs to be improved
const acceptableCommands = [ "arecord -L", 'ipconfig', 'ip addr' ];
if (!acceptableCommands.includes(consoleCommand)) {
log.WARN("Console command is not acceptable: ", consoleCommand);
return undefined;
}
log.DEBUG("Running console command: ", consoleCommand);
const tempOutput = await execCommand(consoleCommand);
const output = tempOutput.stdout.trim();
log.DEBUG("Executed Console Command Response: ", output)
// TODO add some error checking
return output;
}
exports.executeAsyncConsoleCommand = executeAsyncConsoleCommand;
async function returnAlsaDeviceObject() {
const listAlsaDevicesCommand = "arecord -L";
const commandResponse = await executeAsyncConsoleCommand(listAlsaDevicesCommand);
const brokenCommand = String(commandResponse).split('\n');
var devices = [];
var i = 0;
for (const responseLine of brokenCommand) {
if (String(responseLine) && !String(responseLine).match(/^\s/g)) {
const tempDevice = {
id: i,
name: responseLine
}
devices.push(tempDevice);
i += 1;
}
}
return devices;
}
exports.returnAlsaDeviceObject = returnAlsaDeviceObject;

View File

@@ -2,20 +2,25 @@
const { DebugBuilder } = require("../utilities/debugBuilder.js");
const log = new DebugBuilder("client", "httpRequests");
// Config
const config = require("../config/clientConfig");
require('dotenv').config();
// Modules
const http = require("http");
const { isJsonString } = require("./utilities.js");
exports.requestOptions = class requestOptions {
constructor(path, method, hostname = undefined, headers = undefined, port = undefined) {
if (method === "POST"){
this.hostname = hostname ?? config.serverConfig.hostname
this.path = path
this.port = port ?? config.serverConfig.port
this.method = method
if (["POST", "PUT"].includes(method)){
log.VERBOSE("Hostname Vars: ", hostname, process.env.SERVER_HOSTNAME, process.env.SERVER_IP);
if (hostname) this.hostname = hostname;
if (!this.hostname && process.env.SERVER_HOSTNAME) this.hostname = process.env.SERVER_HOSTNAME;
if (!this.hostname && process.env.SERVER_IP) this.hostname = process.env.SERVER_IP;
if (!this.hostname) throw new Error("No server hostname / IP was given when creating a request");
this.path = path;
this.port = port ?? process.env.SERVER_PORT;
this.method = method;
this.headers = headers ?? {
'Content-Type': 'application/json',
}
};
}
}
}
@@ -33,17 +38,36 @@ exports.sendHttpRequest = function sendHttpRequest(requestOptions, data, callbac
res.on('data', (data) => {
const responseObject = {
"statusCode": res.statusCode,
"body": JSON.parse(data)
"body": (isJsonString(data.toString())) ? JSON.parse(data.toString()) : data.toString()
};
log.DEBUG("Response Object: ", responseObject);
log.VERBOSE("Response Object: ", responseObject);
callback(responseObject);
})
}).on('error', err => {
log.ERROR('Error: ', err.message)
if (err.code === "ECONNREFUSED"){
// Bot refused connection, assumed offline
log.WARN("Connection Refused");
}
else log.ERROR('Error: ', err.message, err);
callback(undefined);
// TODO need to handle if the server is down
})
// Write the data to the request and send it
req.write(data)
req.end()
req.write(data);
req.end();
}
exports.onHttpError = function onHttpError(httpStatusCode) {
switch(httpStatusCode){
case 404:
// Endpoint not found
log.WARN("404 received");
break;
default:
// Unhandled HTTP error code
log.ERROR("HTTP request returned with status: ", httpStatusCode)
break;
}
}

View File

@@ -1,9 +0,0 @@
// Debug
const { DebugBuilder } = require("../utilities/debugBuilder.js");
const log = new DebugBuilder("client", "messageHandler");
exports.replyToInteraction = async function replyToInteraction(interaction, message){
interaction.reply({ content: message, fetchReply: true })
.then((message) => log.DEBUG(`Reply sent with content ${message.content}`))
.catch((err) => log.ERROR(err));
}

View File

@@ -134,7 +134,7 @@ class audioConfig {
"instance_name": "audio0",
"device_name": deviceName,
"udp_port": port,
"audio_gain": 1.0,
"audio_gain": 2.0,
"number_channels": 1
}];
}

View File

@@ -1,21 +0,0 @@
exports.calcRmsSync = (arr , n) => {
var square = 0;
var mean = 0;
var root = 0;
// Calculate square.
for (i = 0; i < n; i++) {
square += Math.pow(arr[i], 2);
}
// Calculate Mean.
mean = (square / (n));
// Calculate Root.
root = Math.sqrt(mean);
// Normalize the output
root = root / 10
return root;
}

View File

@@ -3,15 +3,16 @@ const { DebugBuilder } = require("../utilities/debugBuilder.js");
const log = new DebugBuilder("client", "updateConfig");
// Modules
const replace = require('replace-in-file');
const { getFullConfig } = require("./configHandler.js");
class Options {
constructor(key, updatedValue) {
this.files = "./config/clientConfig.js";
this.files = "./.env";
// A regex of the line containing the key in the config file
this.from = new RegExp(`"${key}": (.+),`, "g");
this.from = new RegExp(`${key}="?(.+)"?`, "g");
// Check to see if the value is a string and needs to be wrapped in double quotes
if (typeof updatedValue === "string") this.to = `"${key}": "${updatedValue}",`;
else this.to = `"${key}": ${updatedValue},`;
if (Array(["string", "number"]).includes(typeof updatedValue)) this.to = `${key}="${updatedValue}",`;
else this.to = `${key}=${updatedValue}`;
}
}
@@ -20,7 +21,67 @@ class Options {
* @param updatedId The updated ID assigned to the bot
*/
exports.updateId = (updatedId) => {
this.updateConfig('id', updatedId);
this.updateConfig('CLIENT_ID', updatedId);
}
/**
* Wrapper to update any or all keys in the client config
*
* @param {Object} configObject Object with what keys you wish to update (node object format, will be converted)
* @param {number} configObject.id The ID given to the node to update
* @param {string} configObject.name The name of the node
* @param {string} configObject.ip The IP the server can contact the node on
* @param {number} configObject.port The port the server can contact the node on
* @param {string} configObject.location The physical location of the node
* @returns
*/
exports.updateClientConfig = (configObject) => {
const runningConfig = getFullConfig();
var updatedKeys = []
const configKeys = Object.keys(configObject);
if (configKeys.includes("id")) {
if (runningConfig.id != configObject.id) {
this.updateConfig('CLIENT_ID', configObject.id);
updatedKeys.push({'CLIENT_ID': configObject.id});
process.env.CLIENT_ID = configObject.id;
log.DEBUG("Updated ID to: ", configObject.id);
}
}
if (configKeys.includes("name")) {
if (runningConfig.name != configObject.name) {
this.updateConfig('CLIENT_NAME', configObject.name);
updatedKeys.push({'CLIENT_NAME': configObject.name});
process.env.CLIENT_NAME = configObject.name;
log.DEBUG("Updated name to: ", configObject.name);
}
}
if (configKeys.includes("ip")) {
if (runningConfig.ip != configObject.ip) {
this.updateConfig('CLIENT_IP', configObject.ip);
updatedKeys.push({'CLIENT_IP': configObject.ip});
process.env.CLIENT_IP = configObject.ip;
log.DEBUG("Updated ip to: ", configObject.ip);
}
}
if (configKeys.includes("port")) {
if (runningConfig.port != configObject.port) {
this.updateConfig('CLIENT_PORT', configObject.port);
updatedKeys.push({'CLIENT_PORT': configObject.port});
process.env.CLIENT_PORT = configObject.port;
log.DEBUG("Updated port to: ", configObject.port);
}
}
if (configKeys.includes("location")) {
if (runningConfig.location != configObject.location) {
this.updateConfig('CLIENT_LOCATION', configObject.location);
updatedKeys.push({'CLIENT_LOCATION': configObject.location});
process.env.CLIENT_LOCATION = configObject.location;
log.DEBUG("Updated location to: ", configObject.location);
}
}
return updatedKeys;
}
/**
@@ -37,7 +98,6 @@ exports.updateConfig = function updateConfig(key, value) {
})
}
/**
* Wrapper to write changes to the file
* @param options An instance of the Objects class specified to the key being updated
@@ -46,7 +106,7 @@ exports.updateConfig = function updateConfig(key, value) {
function updateConfigFile(options, callback){
replace(options, (error, changedFiles) => {
if (error) return console.error('Error occurred:', error);
log.DEBUG('Modified files:', changedFiles);
log.VERBOSE('Modified files:', changedFiles);
callback(changedFiles);
});
}

View File

@@ -17,7 +17,7 @@ function writePresets(presets, callback = undefined) {
// Error checking
if (err) throw err;
log.DEBUG("Write Complete");
if (callback) callback()
if (callback) callback(); else return
});
}
@@ -48,12 +48,14 @@ function convertFrequencyToHertz(frequency){
if (Number.isInteger(frequency)) {
log.DEBUG(`${frequency} is an integer.`);
// Check to see if the frequency has the correct length
if (frequency.toString().length >= 7 && frequency.toString().length <= 9) return frequency
if (frequency >= 1000000) return frequency
if (frequency >= 100 && frequency <= 999) return frequency * 1000000
log.WARN("Frequency hasn't matched filters: ", frequency);
}
else {
log.DEBUG(`${frequency} is a float value.`);
// Convert to a string to remove the decimal in place and then correct the length
return converter(frequency).from("MHz").to("Hz");
return parseInt(converter(frequency).from("MHz").to("Hz"));
}
} else {
log.DEBUG(`${frequency} is not a number`);
@@ -70,7 +72,8 @@ function convertFrequencyToHertz(frequency){
exports.getPresets = function getPresets() {
const presetDir = path.resolve("./config/radioPresets.json");
log.DEBUG(`Getting presets from directory: '${presetDir}'`);
return JSON.parse(fs.readFileSync(presetDir));
if (fs.existsSync(presetDir)) return JSON.parse(fs.readFileSync(presetDir));
else return {};
}
/**
@@ -116,5 +119,20 @@ exports.updatePreset = (systemName, callback, { frequencies = undefined, mode =
}
}
/**
* 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
*/
exports.removePreset = (systemName, callback) => {
const presets = this.getPresets();
// Check if a system name was passed
if (systemName in presets) {
delete presets[systemName];
writePresets(presets, callback);
}
}

View File

@@ -0,0 +1,282 @@
// Modules
const { promisify } = require('util');
const { exec, spawn } = require("child_process");
const { resolve, dirname } = require('path');
const radioConfigHelper = require("../utilities/radioConfigHelper");
const presetWrappers = require("../utilities/updatePresets");
const converter = require("convert-units");
const fs = require('fs');
require('dotenv').config();
// Debug
const { DebugBuilder } = require("../utilities/debugBuilder.js");
// Global Vars
const log = new DebugBuilder("client", "executeConsoleCommands");
const execCommand = promisify(exec);
const radioBinPath = process.env.OP25_BIN_PATH;
/**
* An object containing the variables needed to run the local node
*/
exports.nodeObject = class nodeObject {
/**
*
* @param {*} param0._id The ID of the node
* @param {*} param0._name The name of the node
* @param {*} param0._ip The IP that the master can contact the node at
* @param {*} param0._port The port that the client is listening on
* @param {*} param0._location The physical location of the node
* @param {*} param0._nearbySystems An object array of nearby systems
*/
constructor({ _id = null, _name = null, _ip = null, _port = null, _location = null, _nearbySystems = null }) {
this.id = _id;
this.name = _name;
this.ip = _ip;
this.port = _port;
this.location = _location;
this.nearbySystems = _nearbySystems;
}
}
/**
*
* @param {*} consoleCommand
* @returns
*/
exports.executeAsyncConsoleCommand = async function executeAsyncConsoleCommand(consoleCommand) {
// Check to see if the command is a real command
// TODO needs to be improved
const acceptableCommands = [ "arecord -L", 'ipconfig', 'ip addr' ];
if (!acceptableCommands.includes(consoleCommand)) {
log.WARN("Console command is not acceptable: ", consoleCommand);
return undefined;
}
log.DEBUG("Running console command: ", consoleCommand);
const tempOutput = await execCommand(consoleCommand);
const output = tempOutput.stdout.trim();
log.DEBUG("Executed Console Command Response: ", output)
// TODO add some error checking
return output;
}
/**
*
* @param {*} process The process to close
* @returns {undefined} Undefined to replace the existing process in the parent
*/
exports.closeProcessWrapper = async (process) => {
log.INFO("Leaving the server");
if (!process) return undefined;
// Try to close the process gracefully
await process.kill(2);
// Wait 25 seconds and see if the process is still open, if it is force it close
await setTimeout(async () => {
if (process) await process.kill(9);
}, 25000)
return undefined;
}
/**
* This wrapper closes any open radio sessions and the opens a new one
*
* @returns {radioChildProcess} The process of the radio session for use
*/
exports.openRadioSessionWrapper = async (radioChildProcess, presetName) => {
if (radioChildProcess) radioChildProcess = await this.closeProcessWrapper(radioChildProcess);
const configChangeResult = await this.changeCurrentConfigWrapper(presetName);
// Throw an error to the client if the config change ran into an error
if (typeof configChangeResult === "string") return configChangeResult;
if (process.platform === "win32") {
log.DEBUG("Starting Windows OP25");
radioChildProcess = await spawn("C:\\Python310\\python.exe", [getRadioBinPath(), "-c", getRadioConfigPath()], { cwd: dirname(getRadioBinPath()) });
}
else {
log.DEBUG("Starting Linux OP25");
radioChildProcess = await spawn(getRadioBinPath(), ["-c", getRadioConfigPath()], { cwd: dirname(getRadioBinPath()) });
}
log.VERBOSE("Radio Process: ", radioChildProcess);
let fullOutput;
radioChildProcess.stdout.setEncoding('utf8');
radioChildProcess.stdout.on("data", (data) => {
log.VERBOSE("From Process: ", data);
fullOutput += data.toString();
});
radioChildProcess.stderr.on('data', (data) => {
log.VERBOSE(`stderr: ${data}`);
fullOutput += data.toString();
});
radioChildProcess.on('close', (code) => {
log.DEBUG(`child process exited with code ${code}`);
log.VERBOSE("Full output from radio: ", fullOutput);
});
radioChildProcess.on("error", (code, signal) => {
log.ERROR("Error from the radio process: ", code, signal);
});
// Starting the radio application
return radioChildProcess
}
/**
* Update the OP25 config with a preset
*
* @param {*} presetName The preset name to update the OP25 config file with
* @returns
*/
exports.changeCurrentConfigWrapper = async (presetName) => {
// Check if the given config is saved
log.DEBUG("Checking if provided preset is in the config");
const presetIsPresent = await checkIfPresetExists(presetName);
if (!presetIsPresent) return "No preset with given name found in config"; // No preset with the given name is in the config
// Check if the current config is the same as the preset given
try {
const currentConfig = readOP25Config();
if (currentConfig.channels && currentConfig.channels.name === presetName) {
log.DEBUG("Current config is the same as the preset given");
return undefined;
}
}
catch (err) {
log.WARN("Problem reading the config file, overwriting with the new config", err);
}
// Convert radioPreset to OP25 'cfg.json. file
log.DEBUG("Converting radioPreset to OP25 config");
const updatedConfigObject = convertRadioPresetsToOP25Config(presetName);
// Replace current JSON file with the updated file
writeOP25Config(updatedConfigObject, () => {
return updatedConfigObject;
})
}
/**
* Get the location of the 'multi_rx.py' binary from the config
*/
function getRadioBinPath(){
return resolve(radioBinPath);
}
/**
* Get the path of the config for the radio app (OP25) and set the global variable
*/
function getRadioConfigPath(){
let radioConfigDirPath = dirname(getRadioBinPath());
return resolve(`${radioConfigDirPath}/cfg.json`);
}
/**
* Write the given config to the JSON file in OP25 the bin dir
* @param config The full config to be written to the file
* @param {function} callback The function to be called when this wrapper completes
*/
function writeOP25Config(config, callback = undefined) {
log.DEBUG("Updating OP25 config with: ", config);
fs.writeFile(getRadioConfigPath(), JSON.stringify(config), (err) => {
// Error checking
if (err) {
log.ERROR(err);
throw err;
}
log.DEBUG("Write Complete");
if (callback) callback()
});
}
/**
* Get the current config file in use by OP25
* @returns {object|*} The parsed config object currently set in OP25
*/
function readOP25Config() {
const configPath = getRadioConfigPath();
log.DEBUG(`Reading from config path: '${configPath}'`);
const readFile = fs.readFileSync(configPath);
log.VERBOSE("File Contents: ", readFile.toString());
return JSON.parse(readFile);
}
/**
* Check to see if the preset name exists in the config
* @param {string} presetName The system name as saved in the preset
* @returns {true||false}
*/
function checkIfPresetExists(presetName) {
const savedPresets = presetWrappers.getPresets();
log.DEBUG("Found presets: ", savedPresets, presetName, Object.keys(savedPresets).includes(presetName));
if (!Object.keys(savedPresets).includes(presetName)) return false;
else return true;
}
/**
* Convert a radioPreset to OP25's cfg.json file
*/
function convertRadioPresetsToOP25Config(presetName){
const savedPresets = presetWrappers.getPresets();
let frequencyString = "";
for (const frequency of savedPresets[presetName].frequencies){
frequencyString += `${converter(frequency).from("Hz").to("MHz")},`
}
frequencyString = frequencyString.slice(0, -1);
let updatedOP25Config;
switch (savedPresets[presetName].mode){
case "p25":
updatedOP25Config = new radioConfigHelper.P25({
"systemName": presetName,
"controlChannelsString": frequencyString,
"tagsFile": savedPresets[presetName].trunkFile
});
break;
case "nbfm":
//code for nbfm here
updatedOP25Config = new radioConfigHelper.NBFM({
"frequency": frequencyString,
"systemName": presetName
});
break;
default:
throw new Error("Radio mode of selected preset not recognized");
}
log.DEBUG(updatedOP25Config);
return updatedOP25Config;
}
// Convert a buffer from the DB to JSON object
exports.BufferToJson = (buffer) => {
return JSON.parse(buffer.toString());
}
/**
* Check to see if the input is a valid JSON string
*
* @param {*} str The string to check for valud JSON
* @returns {true|false}
*/
exports.isJsonString = (str) => {
try {
JSON.parse(str);
} catch (e) {
return false;
}
return true;
}

View File

@@ -1,11 +1,169 @@
<!DOCTYPE html>
<html>
<head>
<title><%= title %></title>
<link rel='stylesheet' href='/stylesheets/style.css' />
</head>
<body>
<h1><%= title %></h1>
<p>Welcome to <%= title %></p>
</body>
<html lang="en" data-bs-theme="auto">
<head>
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>'<%=node.name%>' - Configuration</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet"
integrity="sha384-9ndCyUaIbzAi2FUVXJi0CjmCapSmO7SnpJef0486qhLnuZ2cdeRhO02iuK6FUUVM" crossorigin="anonymous">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.3.0/font/bootstrap-icons.css">
<link rel="stylesheet" href="/res/css/main.css">
</head>
<body>
<%- include('partials/navbar.ejs') %>
<div aria-live="polite" aria-atomic="true" class="position-relative">
<!-- Position it: -->
<!-- - `.toast-container` for spacing between toasts -->
<!-- - `top-0` & `end-0` to position the toasts in the upper right corner -->
<!-- - `.p-3` to prevent the toasts from sticking to the edge of the container -->
<div class="toast-container top-0 end-0 p-3 max" id="toastZone">
</div>
</div>
<div class="container">
<div class="card mb-4">
<div class="card-header">
<p>
<span class="fs-2 fw-semibold">
Node Details
</span>
</p>
</div>
<div class="card-body">
<div class="col-md-12 pt-2">
<label class="small mb-1" for="nodeStatus">Online Status:</label>
<span class="badge badge-soft-success mb-0 align-middle fs-6" id="nodeStatus">Online</span>
<br>
<div class="py-2"></div>
<!-- Join Server button-->
<a type="button" class="btn btn-info text-white" data-bs-toggle="modal" data-bs-target="#joinModal"
href="#">Join Server</a>
<!-- Leave Server button -->
<a type="button" class="btn btn-danger" href="#" onclick="leaveServer()">Leave Server</a>
<!-- Checkin with client button -->
<a type="button" class="btn btn-secondary" href="#" onclick="sendNodeHeartbeat('<%=node.id%>')">Check-in
with Node</a>
<!-- Update Client button -->
<a type="button" class="btn btn-warning disabled" href="#"
onclick="requestNodeUpdate('<%=node.id%>')">Update Node</a>
</div>
<hr>
<form>
<div class="row gx-3 mb-3">
<div class="col-md-6">
<label class="small mb-1" for="nodeId">Node ID (this is the assigned Node ID and cannot be
changed)</label>
<input class="form-control" id="nodeId" type="text" value="<%=node.id%>" disabled></input>
</div>
</div>
<div class="row gx-3 mb-3">
<div class="col-md-12">
<label class="small mb-1" for="inputNodeName">Node Name:</label>
<input class="form-control" id="inputNodeName" type="text" value="<%=node.name%>"></input>
</div>
</div>
<div class="row gx-3 mb-3">
<div class="col-md-4">
<label class="small mb-1" for="inputNodeIp">Node IP Address (that the server can
contact):</label>
<input class="form-control" id="inputNodeIp" type="text" value="<%=node.ip%>"></input>
</div>
<div class="col-md-2">
<label class="small mb-1" for="inputOrgName">Node Port (with the API):</label>
<input class="form-control" id="inputOrgName" type="number" value="<%=node.port%>"></input>
</div>
</div>
<div class="mb-3">
<label class="small mb-1" for="inputNodeLocation">Node Location (physical location):</label>
<input class="form-control" id="inputNodeLocation" type="location" value="<%=node.location%>"></input>
</div>
<h4>
Nearby Systems
</h4>
<hr>
<div class="row">
<div class="col-lg-12">
<div class="main-box no-header clearfix">
<div class="main-box-body clearfix">
<div class="table-responsive">
<table class="table user-list <% if(!node.online) { %>disabled<% } %>">
<thead>
<tr>
<th><span>System Name</span></th>
<th><span>Frequencies</span></th>
<th><span>Protocol</span></th>
<th>&nbsp;</th>
</tr>
</thead>
<tbody>
<% for(const system in node.nearbySystems){ %>
<tr>
<td>
<%= system %>
</td>
<td>
<% if(node.nearbySystems[system].frequencies.length> 1) { %>
<ul>
<% for(const frequency of node.nearbySystems[system].frequencies) { %>
<li>
<%=frequency%> MHz
</li>
<% } %>
</ul>
<% } else { const frequency=node.nearbySystems[system].frequencies[0] %>
<%=frequency%> MHz
<% } %>
</td>
<td>
<span class="label label-default text-uppercase">
<%= node.nearbySystems[system].mode %>
</span>
</td>
<td>
<a href="#" class="table-link text-info label" data-bs-toggle="modal"
data-bs-target="#updateSystemModal_<%=system.replaceAll(" ", " _")%>">
<i class="bi bi-pencil"></i>
</a>
<a class="table-link text-danger label" onclick="removeSystem('<%=system%>')">
<i class="bi bi-trash"></i>
</a>
</td>
</tr>
<% // Update system modal %>
<%- include("partials/modifySystemModal.ejs", {'system': system, 'frequencies' :
node.nearbySystems[system].frequencies, 'mode' : node.nearbySystems[system].mode}) %>
<% } %>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<!-- Save changes button-->
<button class="btn btn-primary" type="button" onclick="saveNodeDetails()">Save changes</button>
<!-- Button trigger modal -->
<button type="button" class="btn btn-primary float-right" data-bs-toggle="modal"
data-bs-target="#updateSystemModal_New_System">Add New System</button>
</form>
</div>
</div>
</div>
<% // new System Modal %>
<%- include("partials/modifySystemModal.ejs", {'system': "New System" , 'frequencies' : [], 'mode' : '' }) %>
<% // Join Server Modal %>
<%- include("partials/joinModal.ejs", {'node': node}) %>
</body>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"
integrity="sha384-geWF76RCwLtnZ8qwWowPQNguL3RmwHVBC9FhGdlKrxdiJJigb/j/68SIy3Te4Bkz" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.0/jquery.min.js"
integrity="sha512-3gJwYpMe3QewGELv8k/BX9vcqhryRdzRMxVfq6ngyWXwo03GFEzjsUm8Q7RZcHPHksttq7/GFoxjCVUjkjvPdw=="
crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script src="/res/js/node.js"></script>
</html>

View File

@@ -0,0 +1,44 @@
<div class="modal fade" id="joinModal" tabindex="-1" aria-labelledby="joinModal" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="joinModal">Join Node <%=node.id%> to a Discord Server</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="container">
<div class="card mb-4">
<div class="card-body">
<form>
<div class="row gx-3 mb-3">
<div class="col-md-12">
<label class="small mb-1" for="inputDiscordClientId">Discord Client ID:</label>
<input class="form-control" id="inputDiscordClientId" type="text" value="" required></input>
</div>
</div>
<div class="row gx-3 mb-3">
<div class="col-md-6">
<label class="small mb-1" for="inputDiscordChannelId">Discord Channel ID:</label>
<input class="form-control" id="inputDiscordChannelId" type="text" value="" required></input>
</div>
<div class="col-md-6">
<label class="small mb-1" for="selectRadioPreset">Selected Preset:</label>
<select class="custom-select" id="selectRadioPreset">
<% for(const system in node.nearbySystems) { %>
<option value="<%=system%>"><%=system%></option>
<% } %>
</select>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" onclick="joinServer()">Join</button>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,61 @@
<div class="modal fade" id="updateSystemModal_<%=system.replaceAll(" ", "_")%>" tabindex="-1" aria-labelledby="updateSystemModal_<%=system.replaceAll(" ", "_")%>"
aria-hidden="true" data-bs-backdrop="static" data-bs-keyboard="false">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="updateSystemModal_<%=system.replaceAll(" ", "_")%>"><%if (!system == "New System") {%>Update<%} else {%>Add a<%}%> <%=system%></h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="card mb-4">
<div class="card-body">
<form>
<div class="row gx-3 mb-3">
<label class="small mb-1 fs-6" for="systemName">System Name</label>
<input class="form-control" id="<%=system%>_systemName" type="text" value="<%if (system != "New System") {%><%= system %><%} else {%>Local Radio System<%}%>"></input>
</div>
<div class="row gx-3 mb-3" id="frequencyRow_<%=system.replaceAll(" ", "_")%>">
<label class="small mb-1 fs-6" for="systemFreq">Frequencies</label>
<% for(const frequency of frequencies) { %>
<div class="col-md-6 mb-1" id="<%=system%>_systemFreqRow_<%=frequency%>">
<div class="row px-1">
<div class="col-10">
<input class="form-control" id="<%=system%>_systemFreq_<%=frequency%>" type="text" value="<%= frequency %>"></input>
</div>
<div class="col-2">
<a class="align-middle float-left" href="#" onclick="removeFrequencyInput('<%=system%>_systemFreqRow_<%=frequency%>')"><i class="bi bi-x-circle text-black"></i></a>
</div>
</div>
</div>
<% } %>
</div>
<button type="button" class="btn btn-info text-white" onclick="addFrequencyInput('<%=system%>')">Add Frequency</button>
<hr>
<div class="row gx-3 mb-3">
<div class="col-md-6">
<label class="small mb-1 fs-6" for="<%=system%>_systemMode">Mode</label>
<br>
<select class="custom-select" id="<%=system%>_systemMode">
<option value="<%= mode ?? 'select' %>" selected><span class="text-uppercase"><%= mode ?? 'Select' %></span></option>
<% if(mode == "p25") { %>
<option value="nbfm">NBFM</option>
<% } else if (mode == "nbfm") { %>
<option value="p25">P25</option>
<% } else { %>
<option value="nbfm">NBFM</option>
<option value="p25">P25</option>
<%}%>
</select>
</div>
</div>
</form>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal" onclick="location.reload()">Close</button>
<button type="button" class="btn btn-primary" <%if(!system == "New System") {%>onclick="updateSystem('<%=system%>')"<%} else {%>onclick="addNewSystem('<%=system%>')"<%}%>>Save changes</button>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,42 @@
<nav class="navbar fixed-top navbar-expand-lg bg-body-tertiary" data-bs-theme="dark">
<div class="container-fluid">
<a class="navbar-brand" href="#">Node Master</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent"
aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarSupportedContent">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<% /*
<li class="nav-item">
<a class="nav-link active" aria-current="page" href="#">Home</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#">Link</a>
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown"
aria-expanded="false">
Dropdown
</a>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="#">Action</a></li>
<li><a class="dropdown-item" href="#">Another action</a></li>
<li>
<hr class="dropdown-divider">
</li>
<li><a class="dropdown-item" href="#">Something else here</a></li>
</ul>
</li>
*/%>
<li class="nav-item">
<a class="nav-link" id="navbar-notification-bell" onclick="showStoredToasts()"><i class="bi bi-bell-fill"></i></a>
</li>
</ul>
<form class="d-flex" role="search">
<input class="form-control me-2" type="search" placeholder="Search" aria-label="Search">
<button class="btn btn-outline-success" type="submit">Search</button>
</form>
</div>
</div>
</nav>

View File

@@ -1 +0,0 @@
DEBUG="server:*";

58
Server/.env.example Normal file
View File

@@ -0,0 +1,58 @@
# Discord Bot Configs
# Bot Token
TOKEN=""
# Client ID
clientId=""
# Prefix (deprecated)
PREFIX="^"
# ID of the Group than Can Admin The Bot
BOT_ADMINS=""
# Default Voice Channel to Join if None are Specified
DEFAULT_VOICE_CHANNEL_ID=""
# HTTP Server Config (DRB_CNC)
# HTTP Port to listen on
HTTP_PORT=3000
# MySQL Config for Emmelia
# Core DB Info and Login
EM_DB_HOST=""
EM_DB_USER=""
EM_DB_PASS=""
EM_DB_NAME=""
# Names of DB Tables
DB_RSS_FEEDS_TABLE="RSSFeeds"
DB_RSS_POSTS_TABLE="RSSPosts"
DB_ACCOUNTS_TABLE="accounts"
DB_TRANSACTIONS_TABLE="transactions"
DB_PRICING_TABLE="pricing"
# MySQL Config for Node Control
NODE_DB_HOST=''
NODE_DB_USER=''
NODE_DB_PASS=''
NODE_DB_NAME=''
# Node Config
# Time betwen check ins with the nodes
NODE_MONITOR_REFRESH_INTERVAL=100000
# RSS Config
# Interval between refreshes
RSS_REFRESH_INTERVAL=3000000
# OpenAI Config
# OpenAI Organization ID
OPENAI_ORG=""
# OpenAI API Key
OPENAI_KEY=""
# Stable Diffusion (Stability AI) Config
# API KEY
STABILITY_API_KEY=""
# General Config
# Exit when the program encounters and error (this may be ignored in some instances, and the error will exit the program either way)
EXIT_ON_ERROR=false
# Delay the exit of the program for X miliseconds, this can be used if you want to see what happens just after the error occurs or see if something else errors
EXIT_ON_ERROR_DELAY=0

1
Server/.gitignore vendored
View File

@@ -4,3 +4,4 @@ package-lock.json
*.bak
*.log
*._.*
clientIds.json

View File

@@ -1,10 +0,0 @@
## TODOs
- ~~Create balance command for user to view their balance~~
- ~~Create a command that explains the pricing~~
- ~~Update welcome and insufficient replies ~~
- ~~add a section for the help menu to show items that need tokens~~
- add a limiter to the rss feeds to slow down the sends when multiple updates are occurring
- add a blank .env file to the git
- clean up logging
- ensure documentation for functions
- merge with Discord CnC

View File

@@ -0,0 +1,6 @@
{
"[ID from Discord]": {
"name": "[Nickname of the Bot]",
"id": "[Client ID from Discord Dev Portal]"
}
}

View File

@@ -0,0 +1,52 @@
const { SlashCommandBuilder } = require('discord.js');
const { DebugBuilder } = require("../utilities/debugBuilder");
const log = new DebugBuilder("server", "give-role");
module.exports = {
data: new SlashCommandBuilder()
.setName('give-role')
.setDescription('Use this command to give a role you have to another member.')
.addUserOption(option =>
option.setName('user')
.setDescription('The user you wish to give the role to ')
.setRequired(true))
.addRoleOption(option =>
option.setName('role')
.setDescription('The role you wish to give the selected user')
.setRequired(true)),
example: "give-role",
isPrivileged: false,
requiresTokens: false,
defaultTokenUsage: 0,
deferInitialReply: true,
/*async autocomplete(interaction) {
const focusedValue = interaction.options.getFocused();
},*/
async execute(interaction) {
try{
// The role to give to the user
const selectedRole = interaction.options.getRole('role');
// The user who should be given the role
var selectedUser = interaction.options.getUser("user");
selectedUser = interaction.guild.members.cache.get(selectedUser.id);
// The user who initiated the command
const initUser = interaction.member;
log.DEBUG("Give Role DEBUG: ", initUser, selectedRole, selectedUser);
// Check if the user has the role selected
if (!initUser.roles.cache.find(role => role.name === selectedRole.name)) return await interaction.editReply(`Sorry ${initUser}, you don't have the group ${selectedRole} and thus you cannot give it to ${selectedUser}`);
// Give the selected user the role and let both the user and the initiator know
await selectedUser.roles.add(selectedRole);
return await interaction.editReply(`Ok ${initUser}, ${selectedUser} has been given the ${selectedRole} role!`)
}catch(err){
log.ERROR(err)
//await interaction.reply(err.toString());
}
}
};

View File

@@ -50,8 +50,8 @@ module.exports = {
const helpEmbed = new EmmeliaEmbedBuilder()
.setColor(0x0099FF)
.setTitle(`Help`)
.setDescription(`**General Commands**\n\n${generalCommandText}`)
.addFields(
{ name: 'General Commands', value: `${generalCommandText}` },
{ name: 'Paid Commands', value: `${paidCommandText}` }
)
await interaction.reply({ embeds: [helpEmbed], ephemeral: true });

58
Server/commands/join.js Normal file
View File

@@ -0,0 +1,58 @@
// Modules
const { SlashCommandBuilder } = require('discord.js');
const { DebugBuilder } = require("../utilities/debugBuilder");
const { filterAutocompleteValues, filterPresetsAvailable } = require("../utilities/utils");
const { getOnlineNodes, getAllConnections } = require("../utilities/mysqlHandler");
const { joinServerWrapper } = require("../controllers/adminController");
// Global Vars
const log = new DebugBuilder("server", "join");
module.exports = {
data: new SlashCommandBuilder()
.setName('join')
.setDescription('Join the channel you are in with the preset you choose')
.addStringOption(option =>
option.setName("preset")
.setDescription("The preset you would like to listen to")
.setAutocomplete(true)
.setRequired(true)),
example: "join",
isPrivileged: false,
requiresTokens: false,
defaultTokenUsage: 0,
deferInitialReply: true,
async autocomplete(interaction) {
const nodeObjects = await new Promise((recordResolve, recordReject) => {
getOnlineNodes((nodeRows) => {
recordResolve(nodeRows);
});
});
const options = await filterPresetsAvailable(nodeObjects);
// Filter the results to what the user is entering
filterAutocompleteValues(interaction, options);
},
async execute(interaction) {
try{
const guildId = interaction.guild.id;
const presetName = interaction.options.getString('preset');
const channelId = interaction.member.voice.channel.id;
if (!channelId) return interaction.editReply(`You need to be in a voice channel, ${interaction.user}`);
log.DEBUG(`Join requested by: ${interaction.user.username}, to: '${presetName}', in channel: ${channelId} / ${guildId}`);
const connections = await getAllConnections();
log.DEBUG("Current Connections: ", connections);
const selectedClientId = await joinServerWrapper(presetName, channelId, connections);
await interaction.editReply(`Ok, ${interaction.member}. **${selectedClientId.name}** is joining your channel.`);
//await interaction.channel.send('**Pong.**'); // This will send a message to the channel of the interaction outside of the initial reply
}catch(err){
log.ERROR(err)
//await interaction.reply(err.toString());
}
}
}

47
Server/commands/leave.js Normal file
View File

@@ -0,0 +1,47 @@
// Modules
const { SlashCommandBuilder } = require('discord.js');
const { DebugBuilder } = require("../utilities/debugBuilder");
const { getAllClientIds, getKeyByArrayValue, filterAutocompleteValues } = require("../utilities/utils");
const { getAllConnections } = require('../utilities/mysqlHandler');
const { leaveServerWrapper } = require('../controllers/adminController');
// Global Vars
const log = new DebugBuilder("server", "leave");
module.exports = {
data: new SlashCommandBuilder()
.setName('leave')
.setDescription('Disconnect a bot from the server')
.addStringOption(option =>
option.setName("bot")
.setDescription("The bot to disconnect from the server")
.setAutocomplete(true)
.setRequired(true)),
example: "leave",
isPrivileged: false,
requiresTokens: false,
defaultTokenUsage: 0,
deferInitialReply: true,
async autocomplete(interaction) {
const connections = await getAllConnections();
const options = connections.map(conn => conn.clientObject.name);
await filterAutocompleteValues(interaction, options);
},
async execute(interaction) {
try{
const botName = interaction.options.getString('bot');
log.DEBUG("Bot Name: ", botName)
const clinetIds = await getAllClientIds();
log.DEBUG("Client names: ", clinetIds);
const clientDiscordId = getKeyByArrayValue(clinetIds, {'name': botName});
log.DEBUG("Selected bot: ", clinetIds[clientDiscordId]);
await leaveServerWrapper(clinetIds[clientDiscordId]);
await interaction.editReply(`**${clinetIds[clientDiscordId].name}** has been disconnected`); // This will reply to the initial interaction
//await interaction.channel.send('**word.**'); // This will send a message to the channel of the interaction outside of the initial reply
}catch(err){
log.ERROR(err)
//await interaction.reply(err.toString());
}
}
}

View File

@@ -18,6 +18,9 @@ module.exports = {
requiresTokens: false,
defaultTokenUsage: 0,
deferInitialReply: false,
/*async autocomplete(interaction) {
const focusedValue = interaction.options.getFocused();
},*/
async execute(interaction) {
try{
await interaction.channel.send('**Pong.**'); // TODO - Add insults as the response to this command

View File

@@ -7,7 +7,7 @@ const log = new DebugBuilder("server", "remove");
module.exports = {
data: new SlashCommandBuilder()
.setName('remove')
.setDescription('Remove an RSS source by it\' title')
.setDescription('Remove an RSS source by it\'s title')
.addStringOption(option =>
option.setName('title')
.setDescription('The title of the source to remove')

View File

@@ -0,0 +1,37 @@
const { SlashCommandBuilder } = require('discord.js');
const { DebugBuilder } = require("../utilities/debugBuilder");
const log = new DebugBuilder("server", "start-record");
const { getAllConnections } = require("../utilities/mysqlHandler");
const { requestOptions, sendHttpRequest } = require("../utilities/httpRequests");
module.exports = {
data: new SlashCommandBuilder()
.setName('start-record')
.setDescription('Starts recording all bots online'),
example: "start-record",
isPrivileged: false,
requiresTokens: false,
defaultTokenUsage: 0,
deferInitialReply: false,
async execute(interaction) {
try{
await interaction.reply(`Ok, ${interaction.member}. **Recording** will begin shorting.`);
// Get nodes online
getAllConnections((connections) => {
for (const connection of connections){
const reqOptions = new requestOptions("/bot/startRecording", "POST", connection.node.ip, connection.node.port);
sendHttpRequest(reqOptions, JSON.stringify({}), async (responseObj) => {
log.VERBOSE("Response Object from node: ", connection, responseObj);
if (!responseObj || !responseObj.statusCode == 202 || !responseObj.statusCode == 204) return false;
if (responseObj.statusCode >= 300) return log.ERROR(responseObj.body);
// Bot is recording
await interaction.channel.send(`**${connection.clientObject.name} is now recording**`);
});
}
});
}catch(err){
log.ERROR(err)
//await interaction.reply(err.toString());
}
}
};

View File

@@ -0,0 +1,37 @@
const { SlashCommandBuilder } = require('discord.js');
const { DebugBuilder } = require("../utilities/debugBuilder");
const log = new DebugBuilder("server", "stop-record");
const { getAllConnections } = require("../utilities/mysqlHandler");
const { requestOptions, sendHttpRequest } = require("../utilities/httpRequests");
module.exports = {
data: new SlashCommandBuilder()
.setName('stop-record')
.setDescription('Starts recording all bots online'),
example: "stop-record",
isPrivileged: false,
requiresTokens: false,
defaultTokenUsage: 0,
deferInitialReply: false,
async execute(interaction) {
try{
await interaction.reply(`Ok, ${interaction.member}. **Recording** will stop shorting.`);
// Get nodes online
getAllConnections((connections) => {
for (const connection of connections){
const reqOptions = new requestOptions("/bot/stopRecording", "POST", connection.node.ip, connection.node.port);
sendHttpRequest(reqOptions, JSON.stringify({}), async (responseObj) => {
log.VERBOSE("Response Object from node: ", connection, responseObj);
if (!responseObj || !responseObj.statusCode == 204) return false;
if (responseObj.statusCode >= 300) return log.ERROR(responseObj.body);
// Bot is recording
await interaction.channel.send(`**${connection.clientObject.name} has stopped recording**`);
});
}
});
}catch(err){
log.ERROR(err)
//await interaction.reply(err.toString());
}
}
};

View File

@@ -4,9 +4,9 @@ require('dotenv').config();
const { DebugBuilder } = require("../utilities/debugBuilder.js");
const log = new DebugBuilder("server", "adminController");
// Utilities
const mysqlHandler = require("../utilities/mysqlHandler");
const utils = require("../utilities/utils");
const requests = require("../utilities/httpRequests");
const { getAllClientIds } = require("../utilities/utils");
const { getOnlineNodes, updateNodeInfo, addNodeConnection, getConnectionByNodeId, getNodeInfoFromId, checkNodeConnectionByClientId, removeNodeConnectionByNodeId } = require("../utilities/mysqlHandler");
const { requestOptions, sendHttpRequest } = require("../utilities/httpRequests");
/** Get the presets of all online nodes, can be used for functions
*
@@ -14,32 +14,15 @@ const requests = require("../utilities/httpRequests");
* @returns {*} A list of the systems online
*/
async function getPresetsOfOnlineNodes(callback) {
mysqlHandler.getOnlineNodes((onlineNodes) => {
let systems = {};
onlineNodes.forEach(onlineNode => {
systems[onlineNode.id] = utils.BufferToJson(onlineNode.nearbySystems);
getOnlineNodes((onlineNodes) => {
return callback(onlineNodes);
});
return callback(systems);
});
}
async function requestNodeListenToPreset(preset, nodeId, callback) {
mysqlHandler.getNodeInfoFromId(nodeId, (nodeObject) =>{
reqOptions = new requests.requestOptions("/bot/join", "POST", nodeObject.ip, nodeObject.port);
requests.sendHttpRequest(reqOptions, JSON.stringify({
"channelID": process.env.DEFAULT_VOICE_CHANNEL_ID,
"presetName": preset
}), (responseObject) => {
return callback(responseObject)
});
})
}
async function getNodeBotStatus(nodeId, callback) {
mysqlHandler.getNodeInfoFromId(nodeId, (nodeObject) =>{
reqOptions = new requests.requestOptions("/bot/status", "GET", nodeObject.ip, nodeObject.port, undefined, 5);
requests.sendHttpRequest(reqOptions, JSON.stringify({}), (responseObject) => {
getNodeInfoFromId(nodeId, (nodeObject) =>{
reqOptions = new requestOptions("/bot/status", "GET", nodeObject.ip, nodeObject.port, undefined, 5);
sendHttpRequest(reqOptions, JSON.stringify({}), (responseObject) => {
if (responseObject === false) {
// Bot is joined
}
@@ -51,24 +34,6 @@ async function getNodeBotStatus(nodeId, callback) {
});
}
async function requestNodeLeaveServer(nodeId, callback) {
getNodeBotStatus(nodeId, (responseObject) => {
if (responseObject === false) {
// Bot is joined
mysqlHandler.getNodeInfoFromId(nodeId, (nodeObject) =>{
reqOptions = new requests.requestOptions("/bot/leave", "POST", nodeObject.ip, nodeObject.port);
requests.sendHttpRequest(reqOptions, JSON.stringify({}), (responseObject) => {
return callback(responseObject);
});
});
}
else {
// Bot is free
return callback(false);
}
})
}
/** Return to requests for the presets of all online nodes, cannot be used in functions
*
@@ -88,42 +53,160 @@ exports.getAvailablePresets = async (req, res) => {
* @param {*} req Express request parameter
* @var {*} req.body.preset The preset to join (REQ)
* @var {*} req.body.nodeId The specific node to join (OPT/REQ if more than one node has the preset)
* @var {*} req.body.clientId The ID of the client that we want to join with
* @var {*} req.body.channelId The channel Id of the discord channel to join
* @param {*} res Express response parameter
*/
exports.joinPreset = async (req, res) => {
if (!req.body.preset) return res.status(400).json("No preset specified");
await getPresetsOfOnlineNodes((systems) => {
const systemsWithSelectedPreset = Object.values(systems).filter(nodePresets => nodePresets.includes(req.body.preset)).length
if (!systemsWithSelectedPreset) return res.status(400).json("No system online with that preset");
if (systemsWithSelectedPreset > 1) {
if (!req.body.nodeId) return res.status(175).json("Multiple locations with the selected channel, please specify a nodeID (nodeId)")
requestNodeListenToPreset(req.body.preset, req.body.nodeId, (responseObject) => {
if (responseObject === false) return res.status(400).json("Timeout reached");
return res.sendStatus(responseObject.statusCode);
});
}
else {
let nodeId;
if (!req.body.nodeId) nodeId = utils.getKeyByArrayValue(systems, req.body.preset);
else nodeId = req.body.nodeId;
requestNodeListenToPreset(req.body.preset, nodeId, (responseObject) => {
if (responseObject === false) return res.status(400).json("Timeout reached");
return res.sendStatus(responseObject.statusCode);
});
}
});
if (!req.body.nodeId) return res.status(400).json("No node ID specified");
if (!req.body.clientId) return res.status(400).json("No client ID specified");
if (!req.body.channelId) return res.status(400).json("No channel ID specified");
const preset = req.body.preset;
const nodeId = req.body.nodeId;
const clientId = req.body.clientId;
const channelId = req.body.channelId;
const joinedClient = await joinServerWrapper(preset, channelId, clientId, nodeId);
if (!joinedClient) return res.send(400).json("No joined client");
return res.status(200).json(joinedClient);
}
/** Request a node to join the server listening to a specific preset
*
* @param {*} req Express request parameter
* @param {*} res Express response parameter
* @var {*} req.body.nodeId The ID of the node to disconnect
*/
exports.leaveServer = async (req, res) => {
if (!req.body.nodeId) return res.status(400).json("No nodeID specified");
if (!req.body.nodeId) return res.status(400).json("No node ID specified");
requestNodeLeaveServer(req.body.nodeId, (responseObject) => {
if (responseObject === false) return res.status(400).json("Bot not joined to server");
return res.sendStatus(responseObject.statusCode);
});
const nodeId = req.body.nodeId;
const currentConnection = await getConnectionByNodeId(nodeId);
log.DEBUG("Current Connection for node: ", currentConnection);
if (!currentConnection) return res.status(400).json("Node is not connected")
await leaveServerWrapper(currentConnection.clientObject)
return res.status(200).json(currentConnection.clientObject.name);
}
/**
* * This wrapper will check if there is an available node with the requested preset and if so checks for an available client ID to join with
*
* @param {*} presetName The preset name to listen to on the client
* @param {*} channelId The channel ID to join the bot to
* @param {*} connections EITHER A collection of clients that are currently connected OR a single discord client ID (NOT dev portal ID) that should be used to join the server with
* @param {number} nodeId [OPTIONAL] The node ID to join with (will join with another node if given node is not available)
* @returns
*/
async function joinServerWrapper(presetName, channelId, connections, nodeId = 0) {
// Get nodes online
var onlineNodes = await new Promise((recordResolve, recordReject) => {
getOnlineNodes((nodeRows) => {
recordResolve(nodeRows);
});
});
// Check which nodes have the selected preset
onlineNodes = onlineNodes.filter(node => node.presets.includes(presetName));
log.DEBUG("Filtered Online Nodes: ", onlineNodes);
// Check if any nodes with this preset are available
var nodesCurrentlyAvailable = [];
for (const node of onlineNodes) {
const currentConnection = await getConnectionByNodeId(node.id);
log.DEBUG("Checking to see if there is a connection for Node: ", node, currentConnection);
if(!currentConnection) nodesCurrentlyAvailable.push(node);
}
log.DEBUG("Nodes Currently Available: ", nodesCurrentlyAvailable);
// If not, let the user know
if (!nodesCurrentlyAvailable.length > 0) return Error("All nodes with this channel are unavailable, consider swapping one of the currently joined bots.");
// If so, join with the first node
var availableClientIds = await getAllClientIds();
log.DEBUG("All clients: ", Object.keys(availableClientIds));
var selectedClientId;
if (typeof connections === 'string') {
for (const availableClientId of availableClientIds) {
if (availableClientId.discordId != connections ) selectedClientId = availableClientId;
}
}
else {
log.DEBUG("Open connections: ", connections);
for (const connection of connections) {
log.DEBUG("Used Client ID: ", connection);
availableClientIds = availableClientIds.filter(cid => cid.discordId != connection.clientObject.discordId);
}
log.DEBUG("Available Client IDs: ", availableClientIds);
if (!Object.keys(availableClientIds).length > 0) return log.ERROR("All client ID have been used, consider swapping one of the curretly joined bots or adding more Client IDs to the pool.")
selectedClientId = availableClientIds[0];
}
let selectedNode;
if (nodeId > 0) {
for(const availableNode of nodesCurrentlyAvailable){
if (availableNode.id == nodeId) selectedNode = availableNode;
}
}
if (!selectedNode) selectedNode = nodesCurrentlyAvailable[0];
const reqOptions = new requestOptions("/bot/join", "POST", selectedNode.ip, selectedNode.port);
const postObject = {
"channelId": channelId,
"clientId": selectedClientId.clientId,
"presetName": presetName
};
log.INFO("Post Object: ", postObject);
sendHttpRequest(reqOptions, JSON.stringify(postObject), async (responseObj) => {
log.VERBOSE("Response Object from node ", selectedNode, responseObj);
if (!responseObj || !responseObj.statusCode == 200) return false;
// Node has connected to discord
// Updating node Object in DB
const updatedNode = await updateNodeInfo(selectedNode);
log.DEBUG("Updated Node: ", updatedNode);
// Adding a new node connection
const nodeConnection = await addNodeConnection(selectedNode, selectedClientId);
log.DEBUG("Node Connection: ", nodeConnection);
});
return selectedClientId;
}
exports.joinServerWrapper = joinServerWrapper;
/**
*
* @param {*} clientIdObject The client ID object for the node to leave the server. Either 'clientId'||'name' can be set.
* @returns
*/
async function leaveServerWrapper(clientIdObject) {
if (!clientIdObject.clientId || !clientIdObject.name) return log.ERROR("Tried to leave server without client ID and/or Name");
const node = await checkNodeConnectionByClientId(clientIdObject);
reqOptions = new requestOptions("/bot/leave", "POST", node.ip, node.port);
const responseObj = await new Promise((recordResolve, recordReject) => {
sendHttpRequest(reqOptions, JSON.stringify({}), async (responseObj) => {
recordResolve(responseObj);
});
});
log.VERBOSE("Response Object from node ", node, responseObj);
if (!responseObj || !responseObj.statusCode == 202) return false;
// Node has disconnected from discor
// Removing the node connection from the DB
const removedConnection = removeNodeConnectionByNodeId(node.id);
log.DEBUG("Removed Node Connection: ", removedConnection);
return;
}
exports.leaveServerWrapper = leaveServerWrapper;

View File

@@ -2,17 +2,66 @@
const { DebugBuilder } = require("../utilities/debugBuilder.js");
const log = new DebugBuilder("server", "nodesController");
// Utilities
const {getAllNodes, addNewNode, updateNodeInfo, getNodeInfoFromId, getOnlineNodes } = require("../utilities/mysqlHandler");
const { getAllNodes, addNewNode, updateNodeInfo, getNodeInfoFromId, getOnlineNodes } = require("../utilities/mysqlHandler");
const utils = require("../utilities/utils");
const { sendHttpRequest, requestOptions } = require("../utilities/httpRequests.js");
const { nodeObject } = require("../utilities/recordHelper.js");
const refreshInterval = process.env.NODE_MONITOR_REFRESH_INTERVAL ?? 1200000;
const digitalModes = ['p25'];
/**
* Check in with a singular node, mark it offline if it's offline and
*
* @param {*} node The node Object to check in with
*/
async function checkInWithNode(node) {
const reqOptions = new requestOptions("/client/requestCheckIn", "GET", node.ip, node.port)
sendHttpRequest(reqOptions, "", (responseObj) => {
if (responseObj) {
log.DEBUG("Response from: ", node.name, responseObj);
const onlineNode = new nodeObject({ _online: true, _id: node.id });
log.DEBUG("Node update object: ", onlineNode);
updateNodeInfo(onlineNode, (sqlResponse) => {
if (!sqlResponse) this.log.ERROR("No response from SQL object");
log.DEBUG("Updated node: ", sqlResponse);
return true
})
}
else {
log.DEBUG("No response from node, assuming it's offline");
const offlineNode = new nodeObject({ _online: false, _id: node.id });
log.DEBUG("Offline node update object: ", offlineNode);
updateNodeInfo(offlineNode, (sqlResponse) => {
if (!sqlResponse) this.log.ERROR("No response from SQL object");
log.DEBUG("Updated offline node: ", sqlResponse);
return false
})
}
})
}
exports.checkInWithNode = checkInWithNode;
/**
* Check in with all online nodes and mark any nodes that are actually offline
*/
async function checkInWithOnlineNodes() {
getOnlineNodes((nodes) => {
log.DEBUG("Online Nodes: ", nodes);
for (const node of nodes) {
checkInWithNode(node);
}
return;
});
}
exports.checkInWithOnlineNodes = checkInWithOnlineNodes;
/**
*
* @param {*} req
* @param {*} res
* @param {*} req Default express req from router
* @param {*} res Defualt express res from router
*/
exports.listAllNodes = async (req, res) => {
getAllNodes((allNodes) => {
@@ -22,9 +71,13 @@ exports.listAllNodes = async (req, res) => {
});
}
// Add a new node to the storage
/**
* Add a new node to the storage
* @param {*} req Default express req from router
* @param {*} res Defualt express res from router
*/
exports.newNode = async (req, res) => {
if (!req.body.name) return res.send(400)
if (!req.body.name) return res.status(400).json("No name specified for new node");
try {
// Try to add the new user with defaults if missing options
@@ -34,12 +87,12 @@ exports.newNode = async (req, res) => {
_port: req.body.port ?? null,
_location: req.body.location ?? null,
_nearbySystems: req.body.nearbySystems ?? null,
_online: req.body.online ?? 0
_online: (req.body.online == "true" || req.body.online == "True") ? true : false
});
addNewNode(newNode, (queryResults) => {
addNewNode(newNode, (newNodeObject) => {
// Send back a success if the user has been added and the ID for the client to keep track of
res.status(202).json({"nodeId": queryResults.insertId});
res.status(202).json({ "nodeId": newNodeObject.id });
})
}
catch (err) {
@@ -52,88 +105,254 @@ exports.newNode = async (req, res) => {
}
}
// Get the known info for the node specified
/** Get the known info for the node specified
*
* @param {*} req Default express req from router
* @param {*} res Defualt express res from router
*/
exports.getNodeInfo = async (req, res) => {
if (!req.query.id) return res.status(400).json("No id specified");
getNodeInfoFromId(req.query.id, (nodeInfo) => {
if (!req.params.id) return res.status(400).json("No id specified");
getNodeInfoFromId(req.params.id, (nodeInfo) => {
res.status(200).json(nodeInfo);
})
}
// Updates the information received from the client based on ID
exports.nodeCheckIn = async (req, res) => {
if (!req.body.id) return res.status(400).json("No id specified");
getNodeInfoFromId(req.body.id, (nodeInfo) => {
let checkInObject = new nodeObject();
// Convert the DB systems buffer to a JSON object to be worked with
nodeInfo.nearbySystems = utils.BufferToJson(nodeInfo.nearbySystems)
// Convert the online status to a boolean to be worked with
nodeInfo.online = nodeInfo.online !== 0;
/** Adds a specific system/preset on a given node
*
* @param {*} req Default express req from router
* @param {*} res Defualt express res from router
* @param {*} req.params.nodeId The Node ID to add the preset/system to
* @param {*} req.body.systemName The name of the system to add
* @param {*} req.body.mode The radio mode of the preset
* @param {*} req.body.frequencies The frequencies of the preset
* @param {*} req.body.trunkFile The trunk file to use for digital stations
*/
exports.addNodeSystem = async (req, res) => {
if (!req.params.nodeId) return res.status(400).json("No id specified");
if (!req.body.systemName) return res.status(400).json("No system specified");
log.DEBUG("Adding system for node: ", req.params.nodeId, req.body);
getNodeInfoFromId(req.params.nodeId, (node) => {
const reqOptions = new requestOptions("/client/addPreset", "POST", node.ip, node.port);
const reqBody = {
'systemName': req.body.systemName,
'mode': req.body.mode,
'frequencies': req.body.frequencies,
}
if(digitalModes.includes(req.body.mode)) reqBody['trunkFile'] = req.body.trunkFile ?? 'none'
if (req.body.name && req.body.name !== nodeInfo.name) checkInObject.name = req.body.name
if (req.body.ip && req.body.ip !== nodeInfo.ip) checkInObject.ip = req.body.ip
if (req.body.port && req.body.port !== nodeInfo.port) checkInObject.port = req.body.port
if (req.body.location && req.body.location !== nodeInfo.location) checkInObject.location = req.body.location
if (req.body.nearbySystems && JSON.stringify(req.body.nearbySystems) !== JSON.stringify(nodeInfo.nearbySystems)) checkInObject.nearbySystems = req.body.nearbySystems
if (req.body.online && req.body.online !== nodeInfo.online) checkInObject.online = req.body.online
// If no changes are made tell the client
if (Object.keys(checkInObject).length === 0) return res.status(200).json("No keys updated");
log.INFO("Updating the following keys for ID: ", req.body.id, checkInObject);
// Adding the ID key to the body so that the client can double-check their ID
checkInObject.id = req.body.id;
updateNodeInfo(checkInObject, () => {
return res.status(202).json({"updatedKeys": checkInObject});
log.DEBUG("Request body for adding node system: ", reqBody, reqOptions);
sendHttpRequest(reqOptions, JSON.stringify(reqBody), async (responseObj) => {
if(responseObj){
// Good
log.DEBUG("Response from adding node system: ", reqBody, responseObj);
return res.sendStatus(200)
} else {
// Bad
log.DEBUG("No Response from adding Node system");
return res.status(400).json("No Response from adding Node, could be offline");
}
})
})
}
exports.nodeMonitorService = class nodeMonitorService {
constructor() {
/** Updates a specific system/preset on a given node
*
* @param {*} req Default express req from router
* @param {*} res Defualt express res from router
* @param {*} req.params.nodeId The Node ID to update the preset/system on
* @param {*} req.body.systemName The name of the system to update
* @param {*} req.body.mode The radio mode of the preset to
* @param {*} req.body.frequencies The frequencies of the preset
* @param {*} req.body.trunkFile The trunk file to use for digital stations
*/
exports.updateNodeSystem = async (req, res) => {
if (!req.params.nodeId) return res.status(400).json("No id specified");
if (!req.body.systemName) return res.status(400).json("No system specified");
log.DEBUG("Updating system for node: ", req.params.nodeId, req.body);
getNodeInfoFromId(req.params.nodeId, (node) => {
const reqOptions = new requestOptions("/client/updatePreset", "POST", node.ip, node.port);
const reqBody = {
'systemName': req.body.systemName,
'mode': req.body.mode,
'frequencies': req.body.frequencies,
}
if(digitalModes.includes(req.body.mode)) reqBody['trunkFile'] = req.body.trunkFile ?? 'none'
log.DEBUG("Request body for updating node: ", reqBody, reqOptions);
sendHttpRequest(reqOptions, JSON.stringify(reqBody), async (responseObj) => {
if(responseObj){
// Good
log.DEBUG("Response from updating node system: ", reqBody, responseObj);
return res.sendStatus(200)
} else {
// Bad
log.DEBUG("No Response from updating Node system");
return res.status(400).json("No Response from updating Node, could be offline");
}
})
})
}
/** Deletes a specific system/preset from a given node
*
* @param {*} req Default express req from router
* @param {*} res Defualt express res from router
* @param {*} req.params.nodeId The Node ID to update the preset/system on
* @param {*} req.body.systemName The name of the system to update
*/
exports.removeNodeSystem = async (req, res) => {
if (!req.params.nodeId) return res.status(400).json("No id specified");
if (!req.body.systemName) return res.status(400).json("No system specified");
log.DEBUG("Updating system for node: ", req.params.nodeId, req.body);
getNodeInfoFromId(req.params.nodeId, (node) => {
const reqOptions = new requestOptions("/client/removePreset", "POST", node.ip, node.port);
const reqBody = {
'systemName': req.body.systemName
}
async start(){
log.DEBUG("Request body for deleting preset: ", reqBody, reqOptions);
sendHttpRequest(reqOptions, JSON.stringify(reqBody), async (responseObj) => {
if(responseObj){
// Good
log.DEBUG("Response from deleting preset: ", reqBody, responseObj);
return res.sendStatus(200)
} else {
// Bad
log.DEBUG("No Response from deleting preset");
return res.status(400).json("No Response from deleting preset, could be offline");
}
})
})
}
/** Updates the information received from the client based on ID
*
* @param {*} req Default express req from router
* @param {*} res Defualt express res from router
*/
exports.updateExistingNode = async = (req, res) => {
if (!req.params.nodeId) return res.status(400).json("No id specified");
getNodeInfoFromId(req.params.nodeId, (nodeInfo) => {
let checkInObject = {};
// Convert the online status to a boolean to be worked with
log.DEBUG("REQ Body: ", req.body);
var isObjectUpdated = false;
if (req.body.name && req.body.name != nodeInfo.name) {
checkInObject._name = req.body.name;
isObjectUpdated = true;
}
if (req.body.ip && req.body.ip != nodeInfo.ip) {
checkInObject._ip = req.body.ip;
isObjectUpdated = true;
}
if (req.body.port && req.body.port != nodeInfo.port) {
checkInObject._port = req.body.port;
isObjectUpdated = true;
}
if (req.body.location && req.body.location != nodeInfo.location) {
checkInObject._location = req.body.location;
isObjectUpdated = true;
}
if (req.body.nearbySystems && JSON.stringify(req.body.nearbySystems) !== JSON.stringify(nodeInfo.nearbySystems)) {
checkInObject._nearbySystems = req.body.nearbySystems;
isObjectUpdated = true;
}
if (req.body.online != nodeInfo.online || req.body.online && (req.body.online === "true") != nodeInfo.online) {
checkInObject._online = req.body.online;
isObjectUpdated = true;
}
// If no changes are made tell the client
if (!isObjectUpdated) return res.status(200).json("No keys updated");
log.INFO("Updating the following keys for ID: ", req.params.nodeId, checkInObject);
checkInObject._id = req.params.nodeId;
checkInObject = new nodeObject(checkInObject);
if (!nodeInfo) {
log.WARN("No existing node found with this ID, adding node: ", checkInObject);
addNewNode(checkInObject, async (newNode) => {
await checkInWithNode(newNode);
return res.status(201).json({ "updatedKeys": newNode });
});
}
else {
updateNodeInfo(checkInObject, async () => {
await checkInWithNode(nodeInfo);
return res.status(202).json({ "updatedKeys": checkInObject });
});
}
});
}
/** Allows the bots to check in and get any updates from the server
*
* @param {*} req Default express req from router
* @param {*} res Defualt express res from router
*/
exports.nodeCheckIn = async (req, res) => {
if (!req.params.nodeId) return res.status(400).json("No id specified");
getNodeInfoFromId(req.params.nodeId, (nodeInfo) => {
if (!nodeInfo) return this.newNode(req, res);
if (!nodeInfo.online) {
nodeInfo.online = true;
updateNodeInfo(nodeInfo, () => {
return res.status(200).json(nodeInfo);
})
}
else return res.status(200).json(nodeInfo);
});
}
/**
* Requests a specific node to check in with the server, if it's online
*
* @param {*} req Default express req from router
* @param {*} res Defualt express res from router
*/
exports.requestNodeCheckIn = async (req, res) => {
if (!req.params.nodeId) return res.status(400).json("No Node ID supplied in request");
const node = await getNodeInfoFromId(req.params.nodeId);
if (!node) return res.status(400).json("No Node with the ID given");
await checkInWithNode(node);
if (res) res.sendStatus(200);
}
/**
* The node monitor service, this will periodically check in on the online nodes to make sure they are still online
*/
exports.nodeMonitorService = class nodeMonitorService {
constructor() {
this.log = new DebugBuilder("server", "nodeMonitorService");
}
/**
* Start the node monitor service in the background
*/
async start() {
// Wait for the a portion of the refresh period before checking in with the nodes, so the rest of the bot can start
await new Promise(resolve => setTimeout(resolve, refreshInterval/10));
await new Promise(resolve => setTimeout(resolve, refreshInterval / 10));
log.INFO("Starting Node Monitor Service");
// Check in before starting the infinite loop
await this.checkInWithOnlineNodes();
await checkInWithOnlineNodes();
while(true){
while (true) {
// Wait for the refresh interval, then wait for the posts to return, then wait a quarter of the refresh interval to make sure everything is cleared up
await new Promise(resolve => setTimeout(resolve, refreshInterval));
await this.checkInWithOnlineNodes();
await checkInWithOnlineNodes();
await new Promise(resolve => setTimeout(resolve, refreshInterval / 4));
continue;
}
}
async checkInWithOnlineNodes(){
getOnlineNodes((nodes) => {
log.DEBUG("Online Nodes: ", nodes);
for (const node of nodes) {
const reqOptions = new requestOptions("/client/requestCheckIn", "GET", node.ip, node.port)
const request = sendHttpRequest(reqOptions, "", (responseObj) => {
if (responseObj) {
log.DEBUG("Response from: ", node.name, responseObj);
}
else {
log.DEBUG("No response from node, assuming it's offline");
const offlineNode = new nodeObject({ _online: 0, _id: node.id });
log.DEBUG("Offline node update object: ", offlineNode);
updateNodeInfo(offlineNode, (sqlResponse) => {
if (!sqlResponse) log.ERROR("No response from SQL object");
log.DEBUG("Updated node: ", sqlResponse);
})
}
})
}
return;
});
}
}

View File

@@ -8,9 +8,17 @@ const log = new DebugBuilder("server", "interactionCreate");
module.exports = {
name: Events.InteractionCreate,
async execute(interaction) {
if (!interaction.isChatInputCommand()) return;
const command = interaction.client.commands.get(interaction.commandName);
log.VERBOSE("Interaction created for command: ", command);
// Execute autocomplete if the user is checking autocomplete
if (interaction.isAutocomplete()) {
log.DEBUG("Running autocomplete for command: ", command.data.name);
return await command.autocomplete(interaction);
}
// Check if the interaction is a command
if (!interaction.isChatInputCommand()) return;
if (!command) {
log.ERROR(`No command matching ${interaction.commandName} was found.`);

View File

@@ -9,6 +9,6 @@ const log = new DebugBuilder("server", "messageCreate");
module.exports = {
name: Events.MessageCreate,
async execute(interaction) {
await linkCop(interaction);
//await linkCop(interaction);
},
};

View File

@@ -26,7 +26,7 @@ const {
} = require('discord.js');
const client = new Client({
intents: [GatewayIntentBits.GuildMessages, GatewayIntentBits.Guilds]
intents: [GatewayIntentBits.GuildMessages, GatewayIntentBits.Guilds, GatewayIntentBits.GuildPresences, GatewayIntentBits.GuildMembers]
});
prefix = process.env.PREFIX
@@ -118,10 +118,21 @@ const commandFiles = fs.readdirSync(commandsPath).filter(file => file.endsWith('
for (const file of commandFiles) {
const filePath = path.join(commandsPath, file);
const command = require(filePath);
log.DEBUG("Importing command: ", command.data.name);
if (command.data instanceof Promise) {
command.data.then(async (builder) => {
command.data = builder;
log.DEBUG("Importing command: ", command.data.name, command);
// Set a new item in the Collection
// With the key as the command name and the value as the exported module
client.commands.set(command.data.name, command);
});
}
else {
log.DEBUG("Importing command: ", command.data.name, command);
// Set a new item in the Collection
// With the key as the command name and the value as the exported module
client.commands.set(command.data.name, command);
}
}
// Run when the bot is ready

View File

@@ -5,7 +5,7 @@ const { FeedStorage, PostStorage } = require("./libStorage");
const libUtils = require("./libUtils");
const { DebugBuilder } = require("./utilities/debugBuilder");
const log = new DebugBuilder("server", "libCore");
const mysql = require("mysql");
const mysql = require("mysql2");
const UserAgent = require("user-agents");
process.env.USER_AGENT_STRING = new UserAgent({ platform: 'Win32' }).toString();
@@ -32,17 +32,38 @@ var runningPostsToRemove = [{
}]
*/
var runningPostsToRemove = {};
const sourceFailureLimit = process.env.SOURCE_FAILURE_LIMIT ?? 3;
const sourceFailureLimit = process.env.SOURCE_FAILURE_LIMIT ?? 15;
/**
* Wrapper for feeds that cause errors. By default it will wait over a day for the source to come back online before deleting it.
*
* @param {*} sourceURL
* @param {string} sourceURL The URL of the feed source causing issues
*/
exports.removeSource = function removeSource(sourceURL) {
log.INFO("Removing source URL: ", sourceURL);
if (!sourceURL in runningPostsToRemove) {runningPostsToRemove[sourceURL] = 1; return;}
// Check to see if this is the first time this source has been attempted
if (!Object.keys(runningPostsToRemove).includes(sourceURL)) {
runningPostsToRemove[sourceURL] = { count: 1, timestamp: Date.now(), ignoredAttempts: 0 };
return;
}
if (runningPostsToRemove[sourceURL] < sourceFailureLimit) {runningPostsToRemove[sourceURL] += 1; return;}
const backoffDateTimeDifference = (Date.now() - new Date(runningPostsToRemove[sourceURL].timestamp));
const backoffWaitTime = (runningPostsToRemove[sourceURL].count * 30000);
log.DEBUG("Datetime", runningPostsToRemove[sourceURL], backoffDateTimeDifference, backoffWaitTime);
// Check to see if the last error occurred within the backoff period or if we should try again
if (backoffDateTimeDifference <= backoffWaitTime) {
runningPostsToRemove[sourceURL].ignoredAttempts +=1;
return;
}
// Increase the retry counter
if (runningPostsToRemove[sourceURL].count < sourceFailureLimit) {
runningPostsToRemove[sourceURL].count += 1;
runningPostsToRemove[sourceURL].timestamp = Date.now();
return;
}
feedStorage.getRecordBy('link', sourceURL, (err, record) => {
if (err) log.ERROR("Error getting record from feedStorage", err);
@@ -62,13 +83,14 @@ exports.removeSource = function removeSource(sourceURL) {
/**
* Unset a source URL from deletion if the source has not already been deleted
* @param {*} sourceURL The source URL to be unset from deletion
* @returns {*}
*/
exports.unsetRemoveSource = function unsetRemoveSource(sourceURL) {
log.INFO("Unsetting source URL from deletion (if not already deleted): ", sourceURL);
if (!sourceURL in runningPostsToRemove) return;
if (!Object.keys(runningPostsToRemove).includes(sourceURL)) return;
if (runningPostsToRemove[sourceURL] > sourceFailureLimit) return delete runningPostsToRemove[sourceURL];
delete runningPostsToRemove[sourceURL];
return
}
/**

View File

@@ -8,7 +8,7 @@ const { RSSSourceRecord, RSSPostRecord } = require("./utilities/recordHelper");
// Storage Specific Modules
// MySQL
const mysql = require("mysql");
const mysql = require("mysql2");
const rssFeedsTable = process.env.DB_RSS_FEEDS_TABLE;
const rssPostsTable = process.env.DB_RSS_POSTS_TABLE;
@@ -481,10 +481,10 @@ exports.PostStorage = class PostStorage extends Storage {
savePost(_postObject, callback){
const tempCreationDate = returnMysqlTime();
log.DEBUG("Saving Post Object:", _postObject);
if (!_postObject?.postId || !_postObject?.link) {
return callback(new Error("Post object malformed, check the object before saving it", _postObject), undefined)
}
log.DEBUG("Saving Post:", _postObject);
if (_postObject.link.length > 250) _postObject.link = _postObject.link.substring(0, 250);

View File

@@ -4,6 +4,7 @@ const log = new DebugBuilder("server", "libUtils");
const { NodeHtmlMarkdown } = require('node-html-markdown');
const { parse } = require("node-html-parser");
const crypto = require("crypto");
require('dotenv').config();
const imageRegex = /(http(s?):)([/|.|\w|\s|-])*((\.(?:jpg|gif|png|webm))|(\/gallery\/(?:[/|.|\w|\s|-])*))/g;
const youtubeVideoRegex = /((?:https?:)?\/\/)?((?:www|m)\.)?((?:youtube(-nocookie)?\.com|youtu.be))(\/(?:[\w\-]+\?v=|embed\/|v\/)?)([\w\-]+)/g
@@ -55,6 +56,7 @@ exports.onError = (error) => {
throw error;
}
var port = process.env.HTTP_PORT;
var bind = typeof port === 'string'
? 'Pipe ' + port
: 'Port ' + port;

1825
Server/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,30 +4,30 @@
"description": "Discord RSS News Bot",
"main": "index.js",
"dependencies": {
"@discordjs/builders": "~1.4.0",
"@discordjs/rest": "~1.5.0",
"axios": "~1.3.4",
"chatgpt": "~4.7.2",
"cookie-parser": "~1.4.4",
"debug": "~2.6.9",
"discord-api-types": "~0.37.35",
"discord.js": "~14.7.1",
"dotenv": "~16.0.3",
"ejs": "~2.6.1",
"express": "~4.18.2",
"fs": "~0.0.1-security",
"gpt-3-encoder": "~1.1.4",
"http-errors": "~1.6.3",
"jsdoc": "~3.6.7",
"jsonfile": "~6.1.0",
"morgan": "~1.9.1",
"mysql": "~2.18.1",
"node-html-markdown": "~1.3.0",
"node-html-parser": "~6.1.5",
"openai": "~3.1.0",
"parse-files": "~0.1.1",
"rss-parser": "~3.12.0",
"user-agents": "~1.0.1303"
"@discordjs/builders": "^1.6.3",
"@discordjs/rest": "^1.7.1",
"axios": "^1.4.0",
"chatgpt": "^5.2.4",
"cookie-parser": "^1.4.6",
"debug": "^4.3.4",
"discord-api-types": "^0.37.42",
"discord.js": "^14.11.0",
"dotenv": "^16.0.3",
"ejs": "^3.1.9",
"express": "^4.18.2",
"fs": "^0.0.1-security",
"gpt-3-encoder": "^1.1.4",
"http-errors": "*",
"jsdoc": "^4.0.2",
"jsonfile": "^6.1.0",
"morgan": "^1.10.0",
"mysql2": "^3.3.5",
"node-html-markdown": "^1.3.0",
"node-html-parser": "^6.1.5",
"openai": "^3.2.1",
"parse-files": "^0.1.1",
"rss-parser": "^3.13.0",
"user-agents": "^1.0.1393"
},
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",

View File

@@ -0,0 +1,184 @@
.node-card {
position: relative;
display: flex;
flex-direction: column;
min-width: 0;
word-wrap: break-word;
background-color: #fff;
background-clip: border-box;
border: 1px solid #eff0f2;
border-radius: 1rem;
margin-bottom: 24px;
box-shadow: 0 2px 3px #e4e8f0;
}
.avatar-md {
height: 4rem;
width: 4rem;
}
.rounded-circle {
border-radius: 50% !important;
}
.img-thumbnail {
padding: 0.25rem;
background-color: #f1f3f7;
border: 1px solid #eff0f2;
border-radius: 0.75rem;
}
.avatar-title {
align-items: center;
background-color: #3b76e1;
color: #fff;
display: flex;
font-weight: 500;
height: 100%;
justify-content: center;
width: 100%;
}
.bg-soft-primary {
background-color: rgba(59, 118, 225, .25) !important;
}
a {
text-decoration: none !important;
}
.badge-soft-danger {
color: #f56e6e !important;
background-color: rgba(245, 110, 110, .1);
}
.badge-soft-success {
color: #63ad6f !important;
background-color: rgba(99, 173, 111, .1);
}
.mb-0 {
margin-bottom: 0 !important;
}
.badge {
display: inline-block;
padding: 0.25em 0.6em;
font-size: 75%;
font-weight: 500;
line-height: 1;
color: #fff;
text-align: center;
white-space: nowrap;
vertical-align: baseline;
border-radius: 0.75rem;
}
/* Info Card Section */
.info-card {
background-color: #fff;
border-radius: 10px;
border: none;
position: relative;
margin-bottom: 30px;
box-shadow: 0 0.46875rem 2.1875rem rgba(90, 97, 105, 0.1), 0 0.9375rem 1.40625rem rgba(90, 97, 105, 0.1), 0 0.25rem 0.53125rem rgba(90, 97, 105, 0.12), 0 0.125rem 0.1875rem rgba(90, 97, 105, 0.1);
min-height: 85%;
}
.info-card .card-statistic .card-icon-large .bi {
font-size: 110px;
}
.info-card .card-statistic .card-icon {
text-align: center;
line-height: 50px;
margin-left: 15px;
color: #000;
position: absolute;
right: -5px;
top: 20px;
opacity: 0.1;
}
/* Info Card Background Colors */
.l-bg-cherry {
background: linear-gradient(to right, #493240, #f09) !important;
color: #fff;
}
.l-bg-blue-dark {
background: linear-gradient(to right, #373b44, #4286f4) !important;
color: #fff;
}
.l-bg-green-dark {
background: linear-gradient(to right, #0a504a, #38ef7d) !important;
color: #fff;
}
.l-bg-orange-dark {
background: linear-gradient(to right, #a86008, #ffba56) !important;
color: #fff;
}
.l-bg-cyan {
background: linear-gradient(135deg, #289cf5, #84c0ec) !important;
color: #fff;
}
.l-bg-green {
background: linear-gradient(135deg, #23bdb8 0%, #43e794 100%) !important;
color: #fff;
}
.l-bg-orange {
background: linear-gradient(to right, #f9900e, #ffba56) !important;
color: #fff;
}
/* Global Section */
.sidebar-container {
min-height: 95vh;
}
.sidebar {
position: fixed;
top: 5vh;
bottom: 0;
left: 0;
}
/* User table section */
.label {
border-radius: 3px;
font-size: 1.1em;
font-weight: 600;
}
.user-list tbody td .user-subhead {
font-size: 1em;
font-style: italic;
}
.table thead tr th {
text-transform: uppercase;
font-size: 0.875em;
}
.table thead tr th {
border-bottom: 2px solid #e7ebee;
}
.table tbody tr td:first-child {
font-size: 1.125em;
font-weight: 300;
}
.table tbody tr td {
font-size: 0.875em;
vertical-align: middle;
border-top: 1px solid #e7ebee;
padding: 12px 8px;
}

View File

@@ -0,0 +1,407 @@
$(document).ready(async () => {
console.log("Loading stored notifications...");
await loadStoredToasts();
console.log("Showing stored notifications...");
await showStoredToasts();
});
/**
* Gets all toasts stored in local storage
*
* @returns {Object} Object of toasts in storage
*/
function getStoredToasts() {
if (localStorage.getItem("toasts")) {
const storedToasts = JSON.parse(localStorage.getItem("toasts"));
console.log("LOADED STORED TOASTS: ", storedToasts);
navbarUpdateNotificationBellCount(storedToasts);
return storedToasts;
}
else return false
}
/**
* Adds a toast to storage, will not allow duplicates
*
* @param {Date} time The date object from when the toast was created
* @param {*} message The message of the toast
*/
function addToastToStorage(time, message) {
var toasts = [{ 'time': time, 'message': message }]
var storedToasts = getStoredToasts();
console.log("Adding new notification to storage: ", toasts);
if (storedToasts) {
toasts = toasts.concat(storedToasts);
console.log("Combined new and stored notifications: ", toasts);
toasts = toasts.filter((value, index, self) =>
index === self.findIndex((t) => (
t.time === value.time && t.message === value.message
))
)
}
console.log("Deduped stored notifications: ", toasts);
localStorage.setItem("toasts", JSON.stringify(toasts));
navbarUpdateNotificationBellCount(toasts);
}
/**
* Removes a toast from the local storage
*
* @param {Date} time The date object from when the toast was created
* @param {*} message The message of the toast
*/
function removeToastFromStorage(time, message) {
const toastToRemove = { 'time': time, 'message': message }
console.log("Toast to remove: ", toastToRemove);
var toasts = getStoredToasts();
console.log("Stored toasts: ", toasts);
if (toasts.indexOf(toastToRemove)) toasts.splice(toasts.indexOf(toastToRemove) - 1, 1)
console.log("Toasts with selected toast removed: ", toasts);
localStorage.setItem("toasts", JSON.stringify(toasts));
navbarUpdateNotificationBellCount(toasts);
}
/**
* Shows all stored toasts
*/
function showStoredToasts() {
const storedToasts = getStoredToasts();
if (!storedToasts) return
console.log("Loaded stored notifications to show: ", storedToasts);
for (const toast of storedToasts) {
const toastId = `${toast.time}-toast`;
console.log("Showing stored toast: ", toast, toastId);
const toastElement = bootstrap.Toast.getOrCreateInstance(document.getElementById(toastId));
toastElement.show();
}
}
/**
* Loads all toasts stored in the local storage into the DOM of the webpage
*/
function loadStoredToasts() {
const storedToasts = getStoredToasts();
if (!storedToasts) return
console.log("Loaded stored notifications: ", storedToasts);
for (const toast of storedToasts) {
createToast(toast.message, { time: toast.time })
}
}
/**
* Will update the count of notifications on the bell icon in the navbar
*
* @param {Array} storedToasts An array of stored toasts to be counted and updated in the navbar
*/
function navbarUpdateNotificationBellCount(storedToasts) {
const notificationBellIcon = document.getElementById("navbar-notification-bell");
var notificationBellCount = document.getElementById("notification-bell-icon-count");
if (!notificationBellCount) {
notificationBellCount = document.createElement('span');
notificationBellCount.id = "notification-bell-icon-count";
notificationBellCount.classList.add('badge');
notificationBellCount.classList.add('text-bg-secondary');
notificationBellCount.appendChild(document.createTextNode(storedToasts.length));
}
else notificationBellCount.innerHTML = storedToasts.length;
notificationBellIcon.appendChild(notificationBellCount);
}
/**
* Remove a frequency input from the DOM
*
* @param {string} system The system name to add the frequency to
* @param {string} inputId [OPTIONAL] The ID of input, this can be anything unique to this input. If this is not provided the number of frequencies will be used as the ID
*/
function addFrequencyInput(system, inputId = null) {
if (!inputId) inputId = $(`[id^="${system}_systemFreqRow_"]`).length;
// Create new input
var icon = document.createElement('i');
icon.classList.add('bi');
icon.classList.add('bi-x-circle');
icon.classList.add('text-black');
var remove = document.createElement('a');
remove.classList.add('align-middle');
remove.classList.add('float-left');
remove.href = '#'
remove.onclick = () => { removeFrequencyInput(`${system}_systemFreqRow_${inputId}`) }
remove.appendChild(icon);
var childColRemoveIcon = document.createElement('div');
childColRemoveIcon.classList.add('col-2');
childColRemoveIcon.appendChild(remove);
var input = document.createElement('input');
input.classList.add('form-control');
input.id = `${system}_systemFreq_${inputId}`;
input.type = 'text';
var childColInput = document.createElement('div');
childColInput.classList.add('col-10');
childColInput.appendChild(input);
var childRow = document.createElement('div');
childRow.classList.add("row");
childRow.classList.add("px-1");
childRow.appendChild(childColInput);
childRow.appendChild(childColRemoveIcon);
var colParent = document.createElement('div');
colParent.classList.add("col-md-6");
colParent.classList.add("mb-1");
colParent.id = `${system}_systemFreqRow_${inputId}`
colParent.appendChild(childRow);
document.getElementById(`frequencyRow_${system.replaceAll(" ", "_")}`).appendChild(colParent);
}
/**
* Add a toast element to the DOM
*
* @param {*} notificationMessage The message of the notification
* @param {Date} param1.time The date object for when the toast was created, blank if creating new
* @param {boolean} param1.showNow Show the toast now or just store it
* @returns
*/
function createToast(notificationMessage, { time = undefined, showNow = false } = {}) {
if (!time) time = new Date(Date.now());
else time = new Date(Date.parse(time));
const toastTitle = document.createElement('strong');
toastTitle.classList.add('me-auto');
toastTitle.appendChild(document.createTextNode("Server Notification"));
const toastTime = document.createElement('small');
toastTime.appendChild(document.createTextNode(time.toLocaleString()));
const toastClose = document.createElement('button');
toastClose.type = 'button';
toastClose.classList.add('btn-close');
toastClose.ariaLabel = 'Close';
toastClose.setAttribute('data-bs-dismiss', 'toast');
toastClose.onclick = () => { removeToastFromStorage(time.toISOString(), notificationMessage); };
const toastHeader = document.createElement('div');
toastHeader.classList.add('toast-header');
toastHeader.appendChild(toastTitle);
toastHeader.appendChild(toastTime);
toastHeader.appendChild(toastClose);
const toastMessage = document.createElement('p');
toastMessage.classList.add("px-2");
toastMessage.appendChild(document.createTextNode(notificationMessage));
const toastBody = document.createElement('div');
toastBody.classList.add('toast-body');
toastBody.appendChild(toastMessage);
const wrapperDiv = document.createElement('div');
wrapperDiv.classList.add('toast');
//wrapperDiv.classList.add('position-fixed');
wrapperDiv.id = `${time.toISOString()}-toast`;
wrapperDiv.role = 'alert';
wrapperDiv.ariaLive = 'assertive';
wrapperDiv.ariaAtomic = true;
wrapperDiv.setAttribute('data-bs-delay', "7500");
wrapperDiv.setAttribute('data-bs-animation', true);
wrapperDiv.appendChild(toastHeader);
wrapperDiv.appendChild(toastMessage);
document.getElementById("toastZone").appendChild(wrapperDiv);
addToastToStorage(time.toISOString(), notificationMessage);
if (showNow) {
const toastElement = bootstrap.Toast.getOrCreateInstance(document.getElementById(`${time.toISOString()}-toast`));
toastElement.show();
}
return;
}
function sendNodeHeartbeat(nodeId) {
const Http = new XMLHttpRequest();
const url = '/nodes/nodeCheckIn/' + nodeId;
Http.open("GET", url);
Http.send();
Http.onloadend = (e) => {
console.log(Http.responseText)
createToast(Http.responseText, { showNow: true });
}
}
function joinServer() {
const preset = document.getElementById("selectRadioPreset").value;
const nodeId = document.getElementById("nodeId").value;
const clientId = document.getElementById("inputDiscordClientId").value;
const channelId = document.getElementById("inputDiscordChannelId").value;
const reqBody = {
'preset': preset,
'nodeId': nodeId,
'clientId': clientId,
'channelId': channelId
};
console.log(reqBody);
const Http = new XMLHttpRequest();
const url = '/admin/join';
Http.open("POST", url);
Http.setRequestHeader("Content-Type", "application/json");
Http.send(JSON.stringify(reqBody));
Http.onloadend = (e) => {
const responseObject = JSON.parse(Http.responseText)
console.log(Http.status);
console.log(responseObject);
createToast(`${responseObject.name} will join shortly`, { showNow: true });
location.reload();
}
}
function leaveServer() {
const nodeId = document.getElementById("nodeId").value;
const reqBody = {
'nodeId': nodeId
};
const Http = new XMLHttpRequest();
const url = '/admin/leave';
Http.open("POST", url);
Http.setRequestHeader("Content-Type", "application/json");
Http.send(JSON.stringify(reqBody));
Http.onloadend = (e) => {
const responseObject = JSON.parse(Http.responseText)
console.log(Http.status);
console.log(responseObject);
createToast(`${responseObject} is leaving`, { showNow: true });
}
}
function saveNodeDetails() {
const nodeId = document.getElementById("nodeId").value;
const nodeName = document.getElementById("inputNodeName").value;
const nodeIp = document.getElementById("inputNodeIp").value;
const nodePort = document.getElementById("inputOrgName").value;
const nodeLocation = document.getElementById("inputNodeLocation").value;
const reqBody = {
'id': nodeId,
'name': nodeName,
'ip': nodeIp,
'port': nodePort,
'location': nodeLocation
}
console.log("Request Body: ", reqBody);
const Http = new XMLHttpRequest();
const url = '/nodes/' + nodeId;
Http.open("PUT", url);
Http.setRequestHeader("Content-Type", "application/json");
Http.send(JSON.stringify(reqBody));
Http.onloadend = (e) => {
const responseObject = JSON.parse(Http.responseText);
console.log(Http.status);
console.log(responseObject);
createToast(`Node Updated!`);
location.reload();
}
}
function addNewSystem() {
const nodeId = document.getElementById("nodeId").value;
const systemName = document.getElementById(`New System_systemName`).value;
const systemMode = document.getElementById(`New System_systemMode`).value;
const inputSystemFreqs = $(`[id^="New System_systemFreq_"]`);
let systemFreqs = [];
for (const inputFreq of inputSystemFreqs) {
systemFreqs.push(inputFreq.value);
}
const reqBody = {
'systemName': systemName,
'mode': systemMode,
'frequencies': systemFreqs
}
console.log("Request Body: ", reqBody);
const Http = new XMLHttpRequest();
const url = '/nodes/' + nodeId + "/systems";
Http.open("POST", url);
Http.setRequestHeader("Content-Type", "application/json");
Http.send(JSON.stringify(reqBody));
Http.onloadend = (e) => {
const responseObject = Http.responseText
console.log(Http.status);
console.log(responseObject);
createToast(`${systemName} Added!`);
location.reload();
}
}
function updateSystem(systemName) {
const nodeId = document.getElementById("nodeId").value;
const systemMode = document.getElementById(`${systemName}_systemMode`).value;
const inputSystemFreqs = $(`[id^="${systemName}_systemFreq_"]`);
let systemFreqs = [];
for (const inputFreq of inputSystemFreqs) {
systemFreqs.push(inputFreq.value);
}
const reqBody = {
'systemName': systemName,
'mode': systemMode,
'frequencies': systemFreqs
}
console.log("Request Body: ", reqBody);
const Http = new XMLHttpRequest();
const url = '/nodes/' + nodeId + "/systems";
Http.open("PUT", url);
Http.setRequestHeader("Content-Type", "application/json");
Http.send(JSON.stringify(reqBody));
Http.onloadend = (e) => {
const responseObject = Http.responseText;
console.log(Http.status);
console.log(responseObject);
createToast(`${systemName} Updated!`);
location.reload();
}
}
function removeSystem(systemName) {
const nodeId = document.getElementById("nodeId").value;
const reqBody = {
'systemName': systemName,
}
console.log("Request Body: ", reqBody);
const Http = new XMLHttpRequest();
const url = '/nodes/' + nodeId + "/systems";
Http.open("DELETE", url);
Http.setRequestHeader("Content-Type", "application/json");
Http.send(JSON.stringify(reqBody));
Http.onloadend = (e) => {
const responseObject = Http.responseText;
console.log(Http.status);
console.log(responseObject);
createToast(`${systemName} Removed!`);
location.reload();
}
}
function requestNodeUpdate() {
}
function removeFrequencyInput(elementId) {
const element = document.getElementById(elementId);
element.remove();
}

View File

@@ -2,7 +2,11 @@
---
Overview here
The server application acts as the central hub within Discord, providing various functionalities and serving as the main point of communication for the clients. Some of the key features and responsibilities of the server include:
- **RSS Feed Updates**: The server periodically updates text channels with RSS feed updates, keeping users informed about the latest news or information.
- **Server Management Functions / User Requests**: The server includes management functions that allow administrators to control and configure various aspects of the server environment. Users can interact with the server through Discord commands, which range from requesting specific radio presets to updating RSS feeds.
- **API and Web Front End**: The server exposes an API and web front end, providing an interface to view and control all the online clients. This allows users to monitor and manage the available radio presets, as well as perform various administrative tasks.
## Requirements

View File

@@ -1,31 +1,45 @@
const libCore = require("../libCore");
var express = require('express');
var router = express.Router();
const { getAllNodes, getNodeInfoFromId, getAllConnections } = require("../utilities/mysqlHandler");
const { filterPresetsAvailable } = require("../utilities/utils");
/* GET home page. */
router.get('/', (req, res) => {
var sources = libCore.getSources();
//res.render('index', { "sources": sources });
var htmlOutput = "";
sources.forEach(source => {
htmlOutput += `
<div style='margin-bottom:15px;'>
<div> Title: ${source.title} </div>
<div> Link: ${source.link} </div>
<div> category: ${source.category} </div>
</div>
<div>
<hr />
</div>
`
router.get('/', async (req, res) => {
var nodes = await new Promise((recordResolve, recordReject) => {
getAllNodes((nodeRows) => {
recordResolve(nodeRows);
});
res.send(htmlOutput);
});
var connections = await getAllConnections();
var presets = await new Promise((recordResolve, recordReject) => {
getAllNodes((nodeRows) => {
recordResolve(filterPresetsAvailable(nodeRows));
});
});
//var sources = libCore.getSources();
return res.render('index', { 'page': 'index', 'nodes': nodes, 'connections': connections, 'presets': presets });
});
/* GET node controller page. */
router.get('/controller', async (req, res) => {
var nodes = await new Promise((recordResolve, recordReject) => {
getAllNodes((nodeRows) => {
recordResolve(nodeRows);
});
});
//var sources = libCore.getSources();
return res.render('controller', { 'nodes': nodes, 'page': 'controller' });
});
/* GET individual node page. */
router.get('/node/:id', async (req, res) => {
var node = await getNodeInfoFromId(req.params.id);
//var sources = libCore.getSources();
return res.render('node', { 'node': node, 'page': 'node' });
});
module.exports = router;

View File

@@ -5,6 +5,22 @@ const nodesController = require('../controllers/nodesController');
/* GET nodes the server knows */
router.get('/', nodesController.listAllNodes);
// TODO Need to authenticate this request
/* GET the information the server has on a particular node */
router.get('/:nodeId', nodesController.getNodeInfo);
// Update an existing node
router.put('/:nodeId', nodesController.updateExistingNode);
// Add a system to an existing node
router.post('/:nodeId/systems', nodesController.addNodeSystem);
// Update a system on an existing node
router.put('/:nodeId/systems', nodesController.updateNodeSystem);
// Delete a system from an existing node
router.delete('/:nodeId/systems', nodesController.removeNodeSystem);
// TODO Need to authenticate this request
/* POST a new node to the server
*
@@ -21,11 +37,10 @@ router.get('/', nodesController.listAllNodes);
router.post('/newNode', nodesController.newNode);
// TODO Need to authenticate this request
/* GET the information the server has on a particular node */
router.get('/nodeInfo', nodesController.getNodeInfo);
// Client checkin with the server to update client information
router.post('/nodeCheckIn/:nodeId', nodesController.nodeCheckIn);
// TODO Need to authenticate this request
// Client checkin with the server to update information
router.post('/nodeCheckIn', nodesController.nodeCheckIn);
// Request a node to check in with the server
router.get('/nodeCheckIn/:nodeId', nodesController.requestNodeCheckIn);
module.exports = router;

View File

@@ -4,6 +4,7 @@ const debug = require('debug');
require('dotenv').config();
// Modules
const { writeFile } = require('fs');
const { inspect } = require('util');
const logLocation = process.env.LOG_LOCATION;
@@ -34,31 +35,31 @@ exports.DebugBuilder = class DebugBuilder {
this.INFO = (...messageParts) => {
const _info = debug(`${appName}:${fileName}:INFO`);
_info(messageParts);
writeToLog(`${appName}:${fileName}:INFO\t-\t${messageParts.map((messagePart, index, array) => {return JSON.stringify(messagePart)})}`, appName);
writeToLog(`${Date.now().toLocaleString('en-US', { timeZone: 'America/New_York' })} - ${appName}:${fileName}:INFO\t-\t${messageParts.map((messagePart, index, array) => {return inspect(messagePart)})}`, appName);
}
this.DEBUG = (...messageParts) => {
const _debug = debug(`${appName}:${fileName}:DEBUG`);
_debug(messageParts);
writeToLog(`${appName}:${fileName}:DEBUG\t-\t${messageParts.map((messagePart, index, array) => {return JSON.stringify(messagePart)})}`, appName);
writeToLog(`${Date.now().toLocaleString('en-US', { timeZone: 'America/New_York' })} - ${appName}:${fileName}:DEBUG\t-\t${messageParts.map((messagePart, index, array) => {return inspect(messagePart)})}`, appName);
}
this.VERBOSE = (...messageParts) => {
const _verbose = debug(`${appName}:${fileName}:VERBOSE`);
_verbose(messageParts);
writeToLog(`${appName}:${fileName}:VERBOSE\t-\t${messageParts.map((messagePart, index, array) => {return JSON.stringify(messagePart)})}`, appName);
writeToLog(`${Date.now().toLocaleString('en-US', { timeZone: 'America/New_York' })} - ${appName}:${fileName}:VERBOSE\t-\t${messageParts.map((messagePart, index, array) => {return inspect(messagePart)})}`, appName);
}
this.WARN = (...messageParts) => {
const _warn = debug(`${appName}:${fileName}:WARNING`);
_warn(messageParts);
writeToLog(`${appName}:${fileName}:WARNING\t-\t${messageParts.map((messagePart, index, array) => {return JSON.stringify(messagePart)})}`, appName);
writeToLog(`${Date.now().toLocaleString('en-US', { timeZone: 'America/New_York' })} - ${appName}:${fileName}:WARNING\t-\t${messageParts.map((messagePart, index, array) => {return inspect(messagePart)})}`, appName);
}
this.ERROR = (...messageParts) => {
const _error = debug(`${appName}:${fileName}:ERROR`);
_error(messageParts);
writeToLog(`${appName}:${fileName}:ERROR\t-\t${messageParts.map((messagePart, index, array) => {return JSON.stringify(messagePart)})}`, appName);
writeToLog(`${Date.now().toLocaleString('en-US', { timeZone: 'America/New_York' })} - ${appName}:${fileName}:ERROR\t-\t${messageParts.map((messagePart, index, array) => {return inspect(messagePart)})}`, appName);
if (process.env.EXIT_ON_ERROR && process.env.EXIT_ON_ERROR > 0) {
writeToLog("!--- EXITING ---!", appName);
setTimeout(process.exit, process.env.EXIT_ON_ERROR_DELAY ?? 0);

View File

@@ -11,17 +11,18 @@ const path = require('node:path');
const { DebugBuilder } = require("./debugBuilder");
const log = new DebugBuilder("server", "deployCommands");
const commands = [];
var commands = [];
// Grab all the command files from the commands directory you created earlier
const commandsPath = path.resolve(__dirname, '../commands');
const commandFiles = fs.readdirSync(commandsPath).filter(file => file.endsWith('.js'));
exports.deploy = (clientId, guildIDs) => {
log.DEBUG("Deploying commands for: ", guildIDs);
if (Array.isArray(guildIDs)) guildIDs = [guildIDs];
if (!Array.isArray(guildIDs)) guildIDs = [guildIDs];
// Grab the SlashCommandBuilder#toJSON() output of each command's data for deployment
for (const file of commandFiles) {
const command = require(`${path.resolve(commandsPath, file)}`);
log.VERBOSE('Deploying Command: ', command);
commands.push(command.data.toJSON());
}
@@ -47,3 +48,35 @@ exports.deploy = (clientId, guildIDs) => {
})()
}
};
/**
* Remove all commands for a given bot in a given guild
*
* @param {*} clientId The client ID of the bot to remove commands from
* @param {*} guildId The ID of the guild to remove the bot commands from
*/
exports.removeAll = (clientId, guildId) => {
if (!Array.isArray(guildId)) guildIDs = [guildId];
log.DEBUG("Removing commands for: ", clientId, guildIDs);
commands = [];
const rest = new REST({ version: '10' }).setToken(token);
for (const guildId of guildIDs){
(async () => {
try {
log.DEBUG(`Started refreshing ${commands.length} application (/) commands for guild ID: ${guildId}.`);
// The put method is used to fully refresh all commands in the guild with the current set
const data = await rest.put(
Routes.applicationGuildCommands(clientId, guildId),
{ body: commands },
);
log.DEBUG(`Successfully reloaded ${data.length} application (/) commands for guild ID: ${guildId}.`);
} catch (error) {
// And of course, make sure you catch and log any errors!
log.ERROR("ERROR Deploying commands: ", error, "Body from error: ", commands);
}
})()
}
}

View File

@@ -3,6 +3,7 @@ const { DebugBuilder } = require("../utilities/debugBuilder.js");
const log = new DebugBuilder("server", "httpRequests");
// Modules
const http = require("http");
const { isJsonString } = require("./utils.js");
exports.requestOptions = class requestOptions {
/**
@@ -39,7 +40,7 @@ exports.sendHttpRequest = function sendHttpRequest(requestOptions, data, callbac
res.on('data', (data) => {
const responseObject = {
"statusCode": res.statusCode,
"body": (requestOptions.method === "POST") ? JSON.parse(data) : data.toString()
"body": (isJsonString(data.toString())) ? JSON.parse(data.toString()) : data.toString()
};
log.DEBUG("Response Object: ", responseObject);
callback(responseObject);

View File

@@ -1,6 +1,11 @@
require('dotenv').config();
const mysql = require('mysql');
const mysql = require('mysql2');
const utils = require('./utils');
const { nodeObject, clientObject, connectionObject } = require("./recordHelper");
const { DebugBuilder } = require("../utilities/debugBuilder");
const { BufferToJson, getClientObjectByClientID } = require("../utilities/utils");
const log = new DebugBuilder("server", "mysSQLHandler");
const connection = mysql.createPool({
host: process.env.NODE_DB_HOST,
@@ -10,6 +15,61 @@ const connection = mysql.createPool({
});
const nodesTable = `${process.env.NODE_DB_NAME}.nodes`;
const nodeConnectionsTable = `${process.env.NODE_DB_NAME}.node_connections`;
/**
* Return a node object from a single SQL row
*
* @param {object} row The row to convert to a node object
* @returns {nodeObject} The converted node object to be used downstream
*/
function returnNodeObjectFromRow(row) {
if (!isNaN(row.online)) row.online = Boolean(row.online);
else if (row.online == "true" || row.online == "false") row.online = (row.online == "true");
return new nodeObject({
_id: row.id,
_name: row.name,
_ip: row.ip,
_port: row.port,
_location: row.location,
_nearbySystems: BufferToJson(row.nearbySystems),
_online: row.online,
});
}
/**
* Wrapper to convert an array of rows to an array of nodeObjects
*
* @param {array} rows The array of SQL results to be converted into node objects
* @returns {array} An array of node objects
*/
function returnNodeObjectFromRows(rows) {
var i = 0;
for (var row of rows){
log.DEBUG("Row: ", row);
rows[i] = returnNodeObjectFromRow(row);
i += 1;
}
log.DEBUG("Converted Objects from Rows: ", rows);
return rows;
}
/**
* Returns a connection object from an SQL row
*
* @param {*} row The SQL row to convert to a connection object
* @returns {connectionObject}
*/
async function returnConnectionObjectFromRow(row) {
if (Array.isArray(row)) row = row[0]
log.DEBUG("Connection row: ", row);
return new connectionObject({
_connection_id: row.connection_id,
_node: await getNodeInfoFromId(row.id),
_client_object: await getClientObjectByClientID(row.discord_client_id)
});
}
/** Get all nodes the server knows about regardless of status
* @param {*} callback Callback function
@@ -17,17 +77,33 @@ const nodesTable = `${process.env.NODE_DB_NAME}.nodes`;
exports.getAllNodes = (callback) => {
const sqlQuery = `SELECT * FROM ${nodesTable}`
runSQL(sqlQuery, (rows) => {
return callback(rows);
if(!rows || rows.length == 0) callback(undefined);
return callback(returnNodeObjectFromRows(rows));
})
}
/**
* Get all Nodes synchronously **May not be working**
*
* @returns
*/
exports.getAllNodesSync = async () => {
const sqlQuery = `SELECT * FROM ${nodesTable}`
const rows = await runSQL(sqlQuery);
console.log("Rows: ", rows);
return await returnNodeObjectFromRows(rows);
}
/** Get all nodes that have the online status set true (are online)
* @param callback Callback function
*/
exports.getOnlineNodes = (callback) => {
const sqlQuery = `SELECT * FROM ${nodesTable} WHERE online = 1;`
runSQL(sqlQuery, (rows) => {
return callback(rows);
return callback(returnNodeObjectFromRows(rows));
})
}
@@ -35,45 +111,68 @@ exports.getOnlineNodes = (callback) => {
* @param nodeId The ID of the node
* @param callback Callback function
*/
exports.getNodeInfoFromId = (nodeId, callback) => {
async function getNodeInfoFromId(nodeId, callback = undefined) {
if (!nodeId || nodeId == '0' || nodeId == 0 ) throw new Error("No node ID given when trying to fetch node");
log.DEBUG("Getting node from ID: ", nodeId);
const sqlQuery = `SELECT * FROM ${nodesTable} WHERE id = ${nodeId}`
const sqlResponse = await new Promise((recordResolve, recordReject) => {
runSQL(sqlQuery, (rows) => {
recordResolve(rows);
})
});
// Call back the first (and theoretically only) row
// Specify 0 so downstream functions don't have to worry about it
return callback(rows[0]);
})
if (!sqlResponse.length > 0) return (callback) ? callback(false) : false;
return (callback) ? callback(await returnNodeObjectFromRow(sqlResponse[0])) : await returnNodeObjectFromRow(sqlResponse[0]);
}
exports.getNodeInfoFromId = getNodeInfoFromId
/** Add a new node to the DB
* @param nodeObject Node information object
* @param callback Callback function
*/
exports.addNewNode = (nodeObject, callback) => {
exports.addNewNode = async (nodeObject, callback) => {
if (!nodeObject.name) throw new Error("No name provided");
const name = nodeObject.name,
ip = nodeObject.ip,
port = nodeObject.port,
location = nodeObject.location,
nearbySystems = utils.JsonToBuffer(nodeObject.nearbySystems),
online = nodeObject.online;
nearbySystems = utils.JsonToBuffer(nodeObject.nearbySystems ?? {})
var online = nodeObject.online;
if (typeof online === "boolean" || typeof online === "number") {
if (online) online = 1;
else online = 0;
}
const sqlQuery = `INSERT INTO ${nodesTable} (name, ip, port, location, nearbySystems, online) VALUES ('${name}', '${ip}', ${port}, '${location}', '${nearbySystems}', ${online})`;
const sqlResponse = await new Promise((recordResolve, recordReject) => {
runSQL(sqlQuery, (rows) => {
return callback(rows);
recordResolve(rows);
})
});
// Call back the first (and theoretically only) row
// Specify 0 so downstream functions don't have to worry about it
const newNode = await this.getNodeInfoFromId(sqlResponse.insertId);
log.DEBUG("Added new node: ", newNode)
return (callback) ? callback(newNode) : newNode;
}
/** Update the known info on a node
* @param nodeObject Node information object
* @param callback Callback function
*/
exports.updateNodeInfo = (nodeObject, callback) => {
exports.updateNodeInfo = async (nodeObject, callback = undefined) => {
if(!nodeObject.id) throw new Error("Attempted to updated node without providing ID", nodeObject);
const name = nodeObject.name,
ip = nodeObject.ip,
port = nodeObject.port,
location = nodeObject.location,
online = nodeObject.online;
online = nodeObject.online
let queryParams = [],
nearbySystems = nodeObject.nearbySystems;
@@ -91,7 +190,7 @@ exports.updateNodeInfo = (nodeObject, callback) => {
}
let sqlQuery = `UPDATE ${nodesTable} SET`
if (!queryParams || queryParams.length === 0) return callback(undefined);
if (!queryParams || queryParams.length === 0) return (callback) ? callback(undefined) : undefined;
if (queryParams.length === 1) {
sqlQuery = `${sqlQuery} ${queryParams[0]}`
} else {
@@ -110,21 +209,167 @@ exports.updateNodeInfo = (nodeObject, callback) => {
sqlQuery = `${sqlQuery} WHERE id = ${nodeObject.id};`
const sqlResponse = await new Promise((recordResolve, recordReject) => {
runSQL(sqlQuery, (rows) => {
if (rows.affectedRows === 1) return callback(true);
else return callback(rows);
recordResolve(rows);
})
});
if (sqlResponse.affectedRows === 1) return (callback) ? callback(true) : true;
else return (callback) ? callback(await returnNodeObjectFromRows(sqlResponse)) : await returnNodeObjectFromRows(sqlResponse);
}
/**
* Add a new connection to the DB when a bot has been connected to the server
*
* @param {*} nodeObject The node object that is being used for this connection
* @param {*} clientId The client ID Object being used for this connection
* @param {*} callback [OPTIONAL] The callback function to be called with the results, will return otherwise
*/
exports.addNodeConnection = (nodeObject, clientObject, callback = undefined) => {
if (!nodeObject.id || !clientObject.clientId) throw new Error("Tried to add a connection without a client and/or node ID");
const sqlQuery = `INSERT INTO ${nodeConnectionsTable} (id, discord_client_id) VALUES (${nodeObject.id}, '${clientObject.clientId}')`;
const sqlResponse = new Promise((recordResolve, recordReject) => {
runSQL(sqlQuery, (rows) => {
recordResolve(rows);
})
});
if (!sqlResponse) throw new Error("No result from added connection");
return (callback) ? callback(true) : true;
}
/**
* Check what node is connected with a given client ID object
*
* @param {*} clientId The client ID object used to search for a connected node
* @param {*} callback [OPTIONAL] The callback function to be called with the results, return will be used otherwise
*/
exports.checkNodeConnectionByClientId = async (clientId, callback = undefined) => {
if (!clientId.clientId) throw new Error("Tried to check a connection without a client ID");
const sqlQuery = `SELECT * FROM ${nodeConnectionsTable} WHERE discord_client_id = '${clientId.clientId}'`;
const sqlResponse = await new Promise((recordResolve, recordReject) => {
runSQL(sqlQuery, (rows) => {
recordResolve(rows);
})
});
log.VERBOSE("SQL Response from checking connection: ", sqlResponse);
if (!sqlResponse) return (callback) ? callback(undefined) : undefined;
const newNodeObject = await getNodeInfoFromId(sqlResponse[0].id);
log.DEBUG("Node Object from SQL Response: ", newNodeObject);
return (callback) ? callback(newNodeObject) : newNodeObject;
}
/**
* Get a connection by node ID
*
* @param {*} nodeId The ID to search for a connection with
* @param {*} callback [OPTIONAL] The callback function to be called with the results, return will be used otherwise
* @returns {connectionObject}
*/
exports.getConnectionByNodeId = async (nodeId, callback = undefined) => {
const sqlQuery = `SELECT * FROM ${nodeConnectionsTable} WHERE id = '${nodeId}'`;
const sqlResponse = await new Promise((recordResolve, recordReject) => {
runSQL(sqlQuery, (rows) => {
recordResolve(rows);
})
});
log.VERBOSE("SQL Response from checking connection: ", sqlResponse);
if (!sqlResponse | sqlResponse.length == 0) return (callback) ? callback(undefined) : undefined;
const newConnectionObject = await returnConnectionObjectFromRow(sqlResponse)
log.DEBUG("Connection Object from SQL Response: ", newConnectionObject);
return (callback) ? callback(newConnectionObject) : newConnectionObject;
}
/**
* Remove a node connection by the node
*
* @param {*} nodeId The node ID of the node to remove connections of
* @param {*} callback [OPTIONAL] The callback function to callback with the results, return will be used otherwise
* @returns
*/
exports.removeNodeConnectionByNodeId = async (nodeId, callback = undefined) => {
const sqlQuery = `DELETE FROM ${nodeConnectionsTable} WHERE id = '${nodeId}'`;
const sqlResponse = await new Promise((recordResolve, recordReject) => {
runSQL(sqlQuery, (rows) => {
recordResolve(rows);
})
});
log.VERBOSE("SQL Response from removing connection: ", sqlResponse);
if (!sqlResponse) return (callback) ? callback(undefined) : undefined;
return (callback) ? callback(sqlResponse) : sqlResponse;
}
/**
* Gets all connected nodes
*
* @param {*} callback [OPTIONAL] The callback function to callback with the results, return will be used otherwise
* @returns {nodeObject}
*/
exports.getConnectedNodes = async (callback = undefined) => {
const sqlQuery = `SELECT * FROM ${nodeConnectionsTable}`;
const sqlResponse = await new Promise((recordResolve, recordReject) => {
runSQL(sqlQuery, (rows) => {
recordResolve(rows);
})
});
log.VERBOSE("SQL Response from checking connection: ", sqlResponse);
if (!sqlResponse) return (callback) ? callback(undefined) : undefined;
var nodeObjects = []
for (const row of sqlResponse) {
const newNodeObject = await getNodeInfoFromId(row.id);
log.DEBUG("Node Object from SQL Response: ", newNodeObject);
nodeObjects.push(newNodeObject);
}
return (callback) ? callback(nodeObjects) : nodeObjects;
}
/**
* Returns all connections
*
* @param {*} callback [OPTIONAL] The callback function to callback with the results, return will be used otherwise
* @returns {connectionObject}
*/
exports.getAllConnections = async (callback = undefined) => {
const sqlQuery = `SELECT * FROM ${nodeConnectionsTable}`;
const sqlResponse = await new Promise((recordResolve, recordReject) => {
runSQL(sqlQuery, (rows) => {
recordResolve(rows);
})
});
log.VERBOSE("SQL Response from checking connection: ", sqlResponse);
if (!sqlResponse) return (callback) ? callback(undefined) : undefined;
var connectionObjects = []
for (const row of sqlResponse) {
connectionObjects.push(await returnConnectionObjectFromRow(row));
}
return (callback) ? callback(connectionObjects) : connectionObjects;
}
// Function to run and handle SQL errors
function runSQL(sqlQuery, callback, error = (err) => {
console.log(err);
function runSQL(sqlQuery, callback = undefined, error = (err) => {
log.ERROR(err);
throw err;
}) {
connection.query(sqlQuery, (err, rows) => {
if (err) return error(err);
//console.log('The rows are:', rows);
return callback(rows);
log.VERBOSE('Response for query: ', sqlQuery, rows);
return (callback) ? callback(rows) : rows
})
}

View File

@@ -109,18 +109,57 @@ class nodeObject {
* @param {*} param0._ip The IP that the master can contact the node at
* @param {*} param0._port The port that the client is listening on
* @param {*} param0._location The physical location of the node
* @param {*} param0._online An integer representation of the online status of the bot, ie 0=off, 1=on
* @param {*} param0._online True/False if the node is online or offline
* @param {*} param0._nearbySystems An object array of nearby systems
*/
constructor({ _id = null, _name = null, _ip = null, _port = null, _location = null, _nearbySystems = null, _online = null }) {
constructor({ _id = undefined, _name = undefined, _ip = undefined, _port = undefined, _location = undefined, _nearbySystems = undefined, _online = undefined }) {
this.id = _id;
this.name = _name;
this.ip = _ip;
this.port = _port;
this.location = _location;
this.nearbySystems = _nearbySystems;
if (this.nearbySystems) this.presets = Object.keys(_nearbySystems);
this.online = _online;
}
}
exports.nodeObject = nodeObject;
/**
* This object represents a discord bot's client information
*/
class clientObject {
/**
*
* @param {*} param0._discord_id The discord id from the node, as seen when right clicking -> copy ID
* @param {*} param0._name The name of the bot associated with the IDs
* @param {*} param0._client_id The client ID of the bot needed to connect to Discord
*/
constructor({_discord_id = undefined, _name = undefined, _client_id = undefined,}) {
this.discordId = _discord_id;
this.name = _name;
this.clientId = _client_id;
}
}
exports.clientObject = clientObject;
/**
* This object represents a discord node connection
*/
class connectionObject {
/**
*
* @param {*} param0._connection_id The connection ID associated with the connection in the database
* @param {*} param0._node The node associated with the connection
* @param {*} param0._client_object The client object associated with the connection
*/
constructor({_connection_id = undefined, _node = undefined, _client_object}) {
this.connectionId = _connection_id;
this.node = _node;
this.clientObject = _client_object;
}
}
exports.connectionObject = connectionObject;

View File

@@ -1,3 +1,11 @@
// Debug
const { DebugBuilder } = require("../utilities/debugBuilder");
const { clientObject } = require("./recordHelper");
const { readFileSync } = require('fs');
const log = new DebugBuilder("server", "utils");
const logAC = new DebugBuilder("server", "command-autocorrect");
const path = require('path');
// Convert a JSON object to a buffer for the DB
exports.JsonToBuffer = (jsonObject) => {
return Buffer.from(JSON.stringify(jsonObject))
@@ -8,6 +16,46 @@ exports.BufferToJson = (buffer) => {
return JSON.parse(buffer.toString());
}
/**
* **DISUSED**
*
* @param {string} presetName The present name to sanitize
* @returns {string} The sanitized preset name to be used elsewhere
*/
exports.SanitizePresetName = (presetName) => {
return String(presetName).toLowerCase().replace(/[\W_]+/g, "-")
}
/**
* Get online, offline and total members in a guild. Optionally a group can be specified to get members' statuses.
*
* @param interaction Discord interaction object
* @param param0.roleName {OPTIONAL} The role name to check the members in; Defaults to 'Bots'
*/
exports.getMembersInRole = async (interaction, roleName = "Bots") => {
log.DEBUG("Fetching all members");
var guild = await interaction.client.guilds.fetch({ guild: interaction.guild.id, cache: false }); //cache all members in the server
await guild.members.fetch({ cache: false });
await guild.roles.fetch({ cache: false });
log.VERBOSE("Guild: ", guild);
const role = await guild.roles.cache.find(role => role.name === roleName); //the role to check
log.DEBUG("Role to check members from: ", role);
log.DEBUG("Members of role: ", role.members);
// This is not working, can't get the status of the users, rest of join is untested
const onlineMembers = await role.members.filter(member => member.voice.channel !== null);
const offlineMembers = await role.members.filter(member => member.voice.channel === null);
const allMembers = await role.members;
log.VERBOSE("All members: ", allMembers, onlineMembers, offlineMembers)
return {
'online': onlineMembers,
'offline': offlineMembers,
'all': allMembers
}
}
/** Find a key in an object by its value
*
* @param {*} object The object to search
@@ -15,5 +63,101 @@ exports.BufferToJson = (buffer) => {
* @returns The key of the object that contains the value
*/
exports.getKeyByArrayValue = (object, value) => {
return Object.keys(object).find(key => object[key].includes(value));
if (typeof value == "string") return Object.keys(object).find(key => object[key].includes(value));
const valueKey = Object.keys(value)[0];
return Object.keys(object).find(key => (object[key][valueKey] == value[valueKey]));
}
/**
* Check to see if the input is a valid JSON string
*
* @param {*} str The string to check for valud JSON
* @returns {true|false}
*/
exports.isJsonString = (str) => {
try {
JSON.parse(str);
} catch (e) {
return false;
}
return true;
}
/**
* Get all client IDs from the saved JSON file
*
* @returns Object of Client IDs
*/
exports.getAllClientIds = () => {
const jsonClientIds = JSON.parse(readFileSync(path.resolve(__dirname, '../clientIds.json')));
var clientObjects = [];
for (const jsonClientId of Object.keys(jsonClientIds)) {
clientObjects.push(new clientObject({
_discord_id: jsonClientId,
_name: jsonClientIds[jsonClientId].name,
_client_id: jsonClientIds[jsonClientId].id
}))
}
return clientObjects;
}
/**
* Gets a client object froma discord client ID
*
* @param {*} clientId The discord client ID to get the client object of
* @returns {clientObject|undefined}
*/
exports.getClientObjectByClientID = (clientId) => {
const clientObjects = this.getAllClientIds();
log.DEBUG("All client IDs: ", clientObjects);
for (const clientObject of clientObjects) {
if (clientObject.clientId == clientId) {
log.DEBUG("Found client ID from given ID: ", clientObject);
return clientObject
}
}
return undefined
}
/**
* Wrapper to filter auto complete
*
* @param {*} interaction
* @param {*} options
*/
exports.filterAutocompleteValues = async (interaction, options) => {
// Get the command used
const command = interaction.command;
// Find values that start with what the user is entering
const focusedValue = interaction.options.getFocused();
const filtered = options.filter(preset => preset.startsWith(focusedValue));
// Give the query response to the user
logAC.DEBUG("Focused Value: ", command, focusedValue, options, filtered);
await interaction.respond(
filtered.map(option => ({ name: option, value: option })),
);
}
/**
* Filter an array of nodeObjects to get all unique presets within
*
* @param {Array} nodeObjects An array of nodeObjects to get the presets from
* @returns {Array} Presets available from given nodeObjects
*/
exports.filterPresetsAvailable = async (nodeObjects) => {
log.DEBUG("Node objects: ", nodeObjects);
var presetsAvailable = [];
for (const nodeObject of nodeObjects) {
log.DEBUG("Node object: ", nodeObject);
presetsAvailable.push.apply(presetsAvailable, nodeObject.presets);
}
log.DEBUG("All Presets available: ", presetsAvailable);
// Remove duplicates
presetsAvailable = [...new Set(presetsAvailable)];
log.DEBUG("DeDuped Presets available: ", presetsAvailable);
return presetsAvailable;
}

View File

@@ -0,0 +1,11 @@
<%- include('partials/htmlHead.ejs') %>
<div class="container">
<div class="row row-cols-1 row-cols-md-2 row-cols-xl-4">
<% for(const node of nodes) {%>
<%- include('partials/nodeCard.ejs', {'node': node}) %>
<% } %>
</div>
</div>
<%- include('partials/bodyEnd.ejs') %>
<script src="/res/js/node.js"></script>
<%- include('partials/htmlFooter.ejs') %>

View File

@@ -1,11 +1,64 @@
<!DOCTYPE html>
<html>
<head>
<title><%= title %></title>
<link rel='stylesheet' href='/stylesheets/style.css' />
</head>
<body>
<h1><%= title %></h1>
<p>Welcome to <%= title %></p>
</body>
</html>
<%- include('partials/htmlHead.ejs', {'page': page}) %>
<div class="container">
<div class="row row-cols-1 row-cols-md-2 row-cols-xl-4 mt-2">
<%- include('partials/valueChip.ejs', {
'title': 'Nodes in the Network',
'bgColor': "orange-dark",
'value': nodes.length,
'progressPercent': false,
'icon': 'server'
}) %>
<%- include('partials/valueChip.ejs', {
'title': 'Nodes Online',
'bgColor': "green",
'value': nodes.filter(node => node.online).length,
'progressPercent': false,
'icon': 'cpu-fill'
}) %>
<%- include('partials/valueChip.ejs', {
'title': 'Nodes with Discord Connections',
'bgColor': "blue-dark",
'value': connections.length,
'progressPercent': false,
'icon': 'gear-wide-connected'
}) %>
</div>
<div class="my-4">
<p><h3><b>Current Connections</b></h3></p>
</div>
<hr>
<div class="row row-cols-1 row-cols-md-2 row-cols-xl-4">
<% for(const conn of connections) { %>
<%- include('partials/connectionCard.ejs', {'connection': conn}) %>
<%}%>
</div>
<div class="my-4">
<p><h3><b>Online Nodes</b></h3></p>
</div>
<hr>
<div class="row row-cols-1 row-cols-md-2 row-cols-xl-4">
<% for(const node of nodes.filter(node => node.online)) { %>
<%- include('partials/nodeCard.ejs', {'node': node}) %>
<%}%>
</div>
<div class="my-4">
<p><h3><b>Offline Nodes</b></h3></p>
</div>
<hr>
<div class="row row-cols-1 row-cols-md-2 row-cols-xl-4">
<% for(const node of nodes.filter(node => node.online == false)) { %>
<%- include('partials/nodeCard.ejs', {'node': node}) %>
<%}%>
</div>
</div>
<%- include('partials/bodyEnd.ejs') %>
<%- include('partials/htmlFooter.ejs') %>

140
Server/views/node.ejs Normal file
View File

@@ -0,0 +1,140 @@
<%- include('partials/htmlHead.ejs') %>
<div class="container">
<div class="card mb-4">
<div class="card-header">
<p>
<span class="fs-2 fw-semibold">
Node Details
</span>
</p>
</div>
<div class="card-body">
<div class="col-md-12 pt-2">
<label class="small mb-1" for="nodeStatus">Online Status:</label>
<% if(node.online){%> <span class="badge badge-soft-success mb-0 align-middle fs-6" id="nodeStatus">Online</span>
<% } else {%> <span class="badge badge-soft-danger mb-0 align-middle fs-6">Offline</span>
<% } %>
<br>
<div class="py-2"></div>
<!-- Join Server button-->
<a type="button" class="btn btn-info text-white<% if(!node.online) { %>disabled<% } %>" data-bs-toggle="modal" data-bs-target="#joinModal" href="#">Join Server</a>
<!-- Leave Server button -->
<a type="button" class="btn btn-danger <% if(!node.online) { %>disabled<% } %>" href="#" onclick="leaveServer()">Leave Server</a>
<!-- Checkin with client button -->
<a type="button" class="btn btn-secondary" href="#" onclick="sendNodeHeartbeat('<%=node.id%>')">Check-in with Node</a>
<!-- Update Client button -->
<a type="button" class="btn btn-warning disabled" href="#" onclick="requestNodeUpdate('<%=node.id%>')">Update Node</a>
</div>
<hr>
<form>
<div class="row gx-3 mb-3">
<div class="col-md-6">
<label class="small mb-1" for="nodeId">Node ID (this is the assigned Node ID and cannot be
changed)</label>
<input class="form-control" id="nodeId" type="text" value="<%=node.id%>" disabled></input>
</div>
</div>
<div class="row gx-3 mb-3">
<div class="col-md-12">
<label class="small mb-1" for="inputNodeName">Node Name:</label>
<input class="form-control" id="inputNodeName" type="text" value="<%=node.name%>"></input>
</div>
</div>
<div class="row gx-3 mb-3">
<div class="col-md-4">
<label class="small mb-1" for="inputNodeIp">Node IP Address (that the server can
contact):</label>
<input class="form-control" id="inputNodeIp" type="text" value="<%=node.ip%>"></input>
</div>
<div class="col-md-2">
<label class="small mb-1" for="inputOrgName">Node Port (with the API):</label>
<input class="form-control" id="inputOrgName" type="number" value="<%=node.port%>"></input>
</div>
</div>
<div class="mb-3">
<label class="small mb-1" for="inputNodeLocation">Node Location (physical location):</label>
<input class="form-control" id="inputNodeLocation" type="location" value="<%=node.location%>"></input>
</div>
<h4>
Nearby Systems
</h4>
<hr>
<div class="row">
<div class="col-lg-12">
<div class="main-box no-header clearfix">
<div class="main-box-body clearfix">
<div class="table-responsive">
<table class="table user-list <% if(!node.online) { %>disabled<% } %>">
<thead>
<tr>
<th><span>System Name</span></th>
<th><span>Frequencies</span></th>
<th><span>Protocol</span></th>
<th>&nbsp;</th>
</tr>
</thead>
<tbody>
<% for(const system in node.nearbySystems){ %>
<tr>
<td>
<%= system %>
</td>
<td>
<% if(node.nearbySystems[system].frequencies.length> 1) { %>
<ul>
<% for(const frequency of
node.nearbySystems[system].frequencies) { %>
<li>
<%=frequency%> MHz
</li>
<% } %>
</ul>
<% } else { const
frequency=node.nearbySystems[system].frequencies[0]
%>
<%=frequency%> MHz
<% } %>
</td>
<td>
<span class="label label-default text-uppercase">
<%= node.nearbySystems[system].mode %>
</span>
</td>
<td>
<a href="#" class="table-link text-info label"
data-bs-toggle="modal"
data-bs-target="#updateSystemModal_<%=system.replaceAll(" ", "_")%>">
<i class="bi bi-pencil"></i>
</a>
<a class="table-link text-danger label" onclick="removeSystem('<%=system%>')">
<i class="bi bi-trash"></i>
</a>
</td>
</tr>
<% // Update system modal %>
<%- include("partials/modifySystemModal.ejs", {'system': system, 'frequencies': node.nearbySystems[system].frequencies, 'mode': node.nearbySystems[system].mode}) %>
<% } %>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<!-- Save changes button-->
<button class="btn btn-primary <% if(!node.online) { %>disabled<% } %>" type="button" onclick="saveNodeDetails()">Save changes</button>
<!-- Button trigger modal -->
<button type="button" class="btn btn-primary float-right <% if(!node.online) { %>disabled<% } %>" data-bs-toggle="modal"
data-bs-target="#updateSystemModal_New_System">Add New System</button>
</form>
</div>
</div>
</div>
<% // new System Modal %>
<%- include("partials/modifySystemModal.ejs", {'system': "New System", 'frequencies': [], 'mode': ''}) %>
<% // Join Server Modal %>
<%- include("partials/joinModal.ejs", {'node': node}) %>
<%- include('partials/bodyEnd.ejs') %>
<%- include('partials/htmlFooter.ejs') %>

View File

@@ -0,0 +1,14 @@
</div>
</div>
</div>
</body>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"
integrity="sha384-geWF76RCwLtnZ8qwWowPQNguL3RmwHVBC9FhGdlKrxdiJJigb/j/68SIy3Te4Bkz"
crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.0/jquery.min.js"
integrity="sha512-3gJwYpMe3QewGELv8k/BX9vcqhryRdzRMxVfq6ngyWXwo03GFEzjsUm8Q7RZcHPHksttq7/GFoxjCVUjkjvPdw=="
crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script src="/res/js/node.js"></script>

Some files were not shown because too many files have changed in this diff Show More