diff --git a/src/commands/index.js b/src/commands/index.js new file mode 100644 index 0000000..620258d --- /dev/null +++ b/src/commands/index.js @@ -0,0 +1,171 @@ +// @ts-check + +const { userBlacklist, commandPrefix } = require('../config/config') +const { groupMemberships, groups } = require('../config/config2.js') +const { getAllFiles } = require('../utils/file.js') + +class CommandManager { + /** + * @type {import('../controller')} + */ + #controller + + /** + * @type {Record} + */ + #commands = {} + + /** + * @param {import('../controller')} controller + */ + constructor(controller) { + if (!controller.connections.twitch) { + throw new TypeError('controller.connections.twitch not found') + } + + this.#controller = controller + + controller.connections.twitch.onMessage(this.#handleTwitchMessage) + } + + async loadCommands() { + const files = (await getAllFiles(__dirname)).filter(path => path.endsWith('.js') && path !== 'index.js') + + for (const path of files) { + const module = require(path) + + const commands = Array.isArray(module) ? module : [module] + for (const command of commands) { + assertCommand(path, command) + + this.#commands[command.name] = command + } + } + } + + /** + * @param {string} channel + * @param {string} user + * @param {string} text + * @param {object} msg TODO grab type + * + * @returns {Promise} + */ + async #handleTwitchMessage(channel, user, text, msg) { + text = text.trim() + + if (text.length < 2) { + // Not enough for their to be a command + return; + } + + if (userBlacklist.includes(user.toLowerCase())) { + return; + } + + const args = text.split(' ') + if (!args[0].startsWith(commandPrefix)) { + // Not a command (that we care about) + return; + } + + const commandName = args[0].substring(1) + const command = this.#commands[commandName] + if (!command || !command.enabled) { + // Command doesn't exist or it's disabled + return; + } + + if (!canUserPerformCommand(user, command)) { + return + } + + const result = command.run({ + controller: this.#controller, + channel, + user, + args, + msg + }) + + if (typeof result?.then === 'function') { + await result + } + } +} + +/** + * @param {string} file + * @param {any} command + */ +function assertCommand(file, command) { + if (typeof command !== 'object') { + throw new TypeError(`${file}: expected command to be an object, got ${typeof command}`) + } + + if (typeof command.name !== 'string') { + throw new TypeError(`${file}: expected name to be a string, got ${typeof command.name}`) + } + + if (typeof command.enabled !== 'boolean') { + throw new TypeError(`${file}: expected enabled to be a boolean, got ${typeof command.enabled}`) + } + + if (typeof command.permission !== 'undefined' && typeof command.permission === 'object') { + throw new TypeError(`${file}: expected permission to be undefined or object, got ${typeof command.name}`) + } + + if (typeof command.run !== 'function') { + throw new TypeError(`${file}: expected run to be a function, got ${typeof command.run}`) + } +} + +/** + * @param {string} user + * @param {import('./types.d.ts').Command} arg1 + * @returns {boolean} + */ +function canUserPerformCommand(user, { permission }) { + if (!permission) { + // Command doesn't have permissions listed + return true + } + + if (permission.group && user in groupMemberships) { + const userGroup = groupMemberships[user] + if (permission.group === userGroup) { + // User is in the exact group required + return true; + } + + // At this point, the user is in a group but it's not the one that's listed. + // They can still run the command however if their group outranks the + // group that's listed (i.e. admin can run mod commands, but operator + // can't run mod commands) + // + // Noteworthy that the ranks are defined in reverse order, meaning a group + // with a rank of 0 has a higher ranking than a group of 1. + + const minimumRank = groups[permission.group] + const userRank = groups[userGroup] + + if (minimumRank === userRank) { + // Different group, same rank. Shouldn't happen, but there's also nothing + // preventing it from happening + return false; + } + + if (minimumRank > userRank) { + // User has permission + return true; + } + } + + if (permission.users && permission.users.includes(user.toLowerCase())) { + return true + } + + return false +} + +module.exports = CommandManager diff --git a/src/commands/ptzplayaudio.js b/src/commands/ptzplayaudio.js new file mode 100644 index 0000000..47c79b6 --- /dev/null +++ b/src/commands/ptzplayaudio.js @@ -0,0 +1,59 @@ +/** + * @type {Record} + */ +const audioNameToIdMap = { + alarm: 37, + siren: 49, + emergency: 40, + trespassing: 43, + camera: 33, + hello: 1, + despacito: 0, + ringtone: 35, + dog: 44 +} + +const aliases = [ + 'ptzplayaudio', + 'playclip', + 'playaudio' +] + +/** + * @type {Array} + */ +const commands = aliases.map(alias => ({ + name: alias, + enabled: true, + permission: { + group: 'operator' + }, + run: async ({ controller, args }) => { + if (!controller.connections.cameras) { + return; + } + + const speaker = controller.connections.cameras.speaker + + const [, audioName] = args + + // Defaults to alarm + let audioId = 37 // Default to alarm + if (audioName) { + if (audioName in audioNameToIdMap) { + audioId = audioNameToIdMap[audioName] + } else if (audioName !== '') { + const result = Number.parseInt(audioName) + if (isNaN(result)) { + return; + } + + audioId = result + } + } + + await speaker.playAudioClip(audioId) + } +})) + +module.exports = commands diff --git a/src/commands/ptzstopaudio.js b/src/commands/ptzstopaudio.js new file mode 100644 index 0000000..70adfe6 --- /dev/null +++ b/src/commands/ptzstopaudio.js @@ -0,0 +1,27 @@ +const aliases = [ + 'ptzplayaudio', + 'playclip', + 'playaudio' +] + +/** + * @type {Array} + */ +const commands = aliases.map(alias => ({ + name: alias, + enabled: true, + permission: { + group: 'operator' + }, + run: async ({ controller }) => { + if (!controller.connections.cameras) { + return; + } + + const speaker = controller.connections.cameras.speaker + + await speaker.stopAudioClip() + } +})) + +module.exports = commands; diff --git a/src/commands/types.d.ts b/src/commands/types.d.ts new file mode 100644 index 0000000..d7128b4 --- /dev/null +++ b/src/commands/types.d.ts @@ -0,0 +1,36 @@ +/** + * Provides info on who's able to perform a command. + * Note that each of these can be either or + */ +export interface CommandPermissionInfo { + /** + * The minimum group needed to run the command + */ + group: import('../config/types.d.ts').Group + + /** + * The users allowed to perform this command regardless of the group they're om + */ + users?: Array +} + +export interface CommandArgs { + controller: import('../controller') + channel: string; + user: string; + args: string[] + msg: object +} + +export interface Command { + /** + * The name used to run the command in chat + */ + name: string + + enabled: boolean, + + permission?: CommandPermissionInfo + + run: (args: CommandArgs) => void | Promise +} diff --git a/src/config/config.js b/src/config/config.js index 171ca65..7cded5d 100644 --- a/src/config/config.js +++ b/src/config/config.js @@ -208,8 +208,8 @@ const micGroups = { //ADD IP INFO IN ENV //Scene Names in OBS //lowercase, no spaces, no s/es -const axisCameras = ["pasture", "parrot","wolf","wolfindoor","wolfcorner","wolfden2","wolfden","georgie", "georgiewater", "noodle","patchy", "toast","roach", "crow", "crowoutdoor", "fox", "foxden", - "foxcorner", "hank", "hankcorner", "marmoset", "marmosetindoor", "chin", "pushpop", "marty", "bb","construction","chicken", "garden","speaker"]; +const axisCameras = /** @type {const} */ (["pasture", "parrot","wolf","wolfindoor","wolfcorner","wolfden2","wolfden","georgie", "georgiewater", "noodle","patchy", "toast","roach", "crow", "crowoutdoor", "fox", "foxden", + "foxcorner", "hank", "hankcorner", "marmoset", "marmosetindoor", "chin", "pushpop", "marty", "bb","construction","chicken", "garden","speaker"]); //Axis Camera Mapping to Command. Converting base to source name const axisCameraCommandMapping = { diff --git a/src/config/config2.js b/src/config/config2.js new file mode 100644 index 0000000..598c30a --- /dev/null +++ b/src/config/config2.js @@ -0,0 +1,220 @@ +// @ts-check + +// temp file just to leave config.js alone for now + +const groups = /** @type {const} */ ({ + admin: 0, + superUser: 1, + mod: 2, + operator: 3, + vip: 4, + user: 5 +}) + +/** + * @type {Record} + */ +const groupMemberships = { + "spacevoyage": 'admin', + "maya": 'admin', + "theconnorobrien": 'admin', + "alveussanctuary": 'admin', + + "ellaandalex": 'superUser', + "dionysus1911": 'superUser', + "dannydv": 'superUser', + "maxzillajr": 'superUser', + "illjx": 'superUser', + "kayla_alveus": 'superUser', + "alex_b_patrick": 'superUser', + "lindsay_alveus": 'superUser', + "strickknine": 'superUser', + "tarantulizer": 'superUser', + "spiderdaynightlive": 'superUser', + "srutiloops": 'superUser', + "evantomology": 'superUser', + "amanda2815": 'superUser', + + "96allskills": 'mod', + "dansza": 'mod', + "echoskope": 'mod', + "loganrx_": 'mod', + "mattipv4": 'mod', + "mik_mwp": 'mod', + "pjeweb": 'mod', + "shrezno": 'mod', + + "stolenarmy_": 'operator', + "berlac": 'operator', + "merger3": 'operator', + "nitelitedf": 'operator', + "fixterjake14": 'operator', + "purplemartinconservation": 'operator', + "wazix11": 'operator', + "lazygoosepxls": 'operator', + "alxiszzz": 'operator', + "shutupleonard": 'operator', + "taizun": 'operator', + "lumberaxe1": 'operator', + "glennvde": 'operator', + "wolfone_": 'operator', + "dohregard": 'operator', + "lakel1": 'operator', + "darkrow_": 'operator', + "minipurrl": 'operator', + "gnomechildboi": 'operator', + "danman149": 'operator', + "hunnybeehelen": 'operator', + "strangecyan": 'operator', + "viphippo": 'operator', + "bagel_deficient": 'operator', + "rhinofriend": 'operator', + "ponchobee": 'operator', + "orophia": 'operator', + + "tfries_": 'vip', + "sivvii_": 'vip', + "ghandii_": 'vip', + "axialmars": 'vip', + "jazz_peru": 'vip', + "stealfydoge": 'vip', + "xano218": 'vip', + "experimentalcyborg": 'vip', + "klav___": 'vip', + "monkarooo": 'vip', + "nixxform": 'vip', + "madcharliekelly": 'vip', + "josh_raiden": 'vip', + "jateu": 'vip', + "storesE6": 'vip', + "rebecca_h9": 'vip', + "matthewde": 'vip', + "user_11_11": 'vip', + "huniebeexd": 'vip', + "kurtyykins": 'vip', + "breacherman": 'vip', + "bryceisrightjr": 'vip', + "sumaxu": 'vip', + "mariemellie": 'vip', + "ewok_626": 'vip', + "quokka64": 'vip', + "nov1cegg": 'vip', + "casualruffian": 'vip', + "likethecheesebri": 'vip', + "otsargh": 'vip', + "just_some_donkus": 'vip', + "fiveacross": 'vip', + "itszalndrin": 'vip', + "ohnonicoleio": 'vip', + "fishymeep": 'vip' +} + +// /** +// * @type {Record>} +// */ +// const groupMemberships = { +// admin: [ +// "spacevoyage", +// "maya", +// "theconnorobrien", +// "alveussanctuary" +// ], +// superUser: [ +// "ellaandalex", +// "dionysus1911", +// "dannydv", +// "maxzillajr", +// "illjx", +// "kayla_alveus", +// "alex_b_patrick", +// "lindsay_alveus", +// "strickknine", +// "tarantulizer", +// "spiderdaynightlive", +// "srutiloops", +// "evantomology", +// "amanda2815" +// ], +// mod: [ +// "96allskills", +// "dansza", +// "echoskope", +// "loganrx_", +// "mattipv4", +// "mik_mwp", +// "pjeweb", +// "shrezno" +// ], +// operator: [ +// "stolenarmy_", +// "berlac", +// "merger3", +// "nitelitedf", +// "fixterjake14", +// "purplemartinconservation", +// "wazix11", +// "lazygoosepxls", +// "alxiszzz", +// "shutupleonard", +// "taizun", +// "lumberaxe1", +// "glennvde", +// "wolfone_", +// "dohregard", +// "lakel1", +// "darkrow_", +// "minipurrl", +// "gnomechildboi", +// "danman149", +// "hunnybeehelen", +// "strangecyan", +// "viphippo", +// "bagel_deficient", +// "rhinofriend", +// "ponchobee", +// "orophia" +// ], +// vip: [ +// "tfries_", +// "sivvii_", +// "ghandii_", +// "axialmars", +// "jazz_peru", +// "stealfydoge", +// "xano218", +// "experimentalcyborg", +// "klav___", +// "monkarooo", +// "nixxform", +// "madcharliekelly", +// "josh_raiden", +// "jateu", +// "storesE6", +// "rebecca_h9", +// "matthewde", +// "user_11_11", +// "huniebeexd", +// "kurtyykins", +// "breacherman", +// "bryceisrightjr", +// "sumaxu", +// "mariemellie", +// "ewok_626", +// "quokka64", +// "nov1cegg", +// "casualruffian", +// "likethecheesebri", +// "otsargh", +// "just_some_donkus", +// "fiveacross", +// "itszalndrin", +// "ohnonicoleio", +// "fishymeep" +// ], +// user: [] +// } + +module.exports = { + groups, + groupMemberships, +} diff --git a/src/config/types.d.ts b/src/config/types.d.ts new file mode 100644 index 0000000..d71fb56 --- /dev/null +++ b/src/config/types.d.ts @@ -0,0 +1 @@ +export type Group = keyof typeof import('./config2.js').groups diff --git a/src/connections/api.js b/src/connections/api.js index 1d8e89e..9eb8d96 100644 --- a/src/connections/api.js +++ b/src/connections/api.js @@ -156,6 +156,9 @@ class API { } } +/** + * @typedef {API} APIConnection + */ module.exports = (controller) => { const wsUrl = process.env.PUBLIC_WS_URL; // Get the Public WS server URL const secretKey = process.env.JWT_SECRET; // Get the JWT decrypt token diff --git a/src/connections/cameras.js b/src/connections/cameras.js index 24240ca..c59e064 100644 --- a/src/connections/cameras.js +++ b/src/connections/cameras.js @@ -496,6 +496,8 @@ class Axis { * * `controller.connections.cameras` is an object of Axis camera connections * + * @typedef {Record} CamerasConnection + * * @param {import("../controller")} controller * @returns {Promise} */ diff --git a/src/connections/courier.js b/src/connections/courier.js index 9f62647..eb5953a 100644 --- a/src/connections/courier.js +++ b/src/connections/courier.js @@ -289,6 +289,8 @@ class Courier { * * `controller.connections.courier` is the Courier instance * + * @typedef {Courier} CourierConnection + * * @param {import("../controller")} controller * @returns {Promise} */ diff --git a/src/connections/database.js b/src/connections/database.js index 2aea96c..fd15dba 100644 --- a/src/connections/database.js +++ b/src/connections/database.js @@ -45,6 +45,8 @@ class Database { * * `controller.connections.database` is the database connection * + * @typedef {Database} DatabaseConnection + * * @param {import("../controller")} controller * @returns {Promise} */ diff --git a/src/connections/obs.js b/src/connections/obs.js index 1c92f0e..32d7249 100644 --- a/src/connections/obs.js +++ b/src/connections/obs.js @@ -1274,6 +1274,12 @@ const create = async (connection) => { * `controller.connections.obs.local` is the local OBS instance * `controller.connections.obs.cloud` is the cloud OBS instance * `controller.connections.obs.create` is a method to create new OBS instances + * + * @typedef {{ + * local: OBS + * cloud: OBS + * create: create + * }} OBSConnection * * @param {import("../controller")} controller * @returns {Promise} diff --git a/src/connections/obsbot.js b/src/connections/obsbot.js index fe576b6..fe405d3 100644 --- a/src/connections/obsbot.js +++ b/src/connections/obsbot.js @@ -346,6 +346,8 @@ class OBSBot { * * `controller.connections.obsBot` is the OBSBot connection * + * @typedef {OBSBot} OBSBotConnection + * * @param {import("../controller")} controller * @returns {Promise} */ diff --git a/src/connections/twitch.js b/src/connections/twitch.js index 8520f6d..4802724 100644 --- a/src/connections/twitch.js +++ b/src/connections/twitch.js @@ -271,6 +271,8 @@ class Twitch { * * `controller.connections.twitch` is the Twitch instance * + * @typedef {Twitch} TwitchConnection + * * @param {import("../controller")} controller * @returns {Promise} */ diff --git a/src/connections/unifi.js b/src/connections/unifi.js index f233309..d28eae0 100644 --- a/src/connections/unifi.js +++ b/src/connections/unifi.js @@ -4,6 +4,10 @@ const Logger = require("../utils/logger"); const logger = new Logger("connections/unifi"); +/** + * @typedef {Unifi} Unifi + */ + class Unifi { /** * Regex to check valid MAC address diff --git a/src/controller.js b/src/controller.js index 22f7b37..57222b5 100644 --- a/src/controller.js +++ b/src/controller.js @@ -1,25 +1,28 @@ -const { readdir } = require("node:fs/promises"); -const { resolve, relative } = require("node:path"); +// @ts-check -const Logger = require("./utils/logger"); +const { relative } = require("node:path"); -/** - * Get all files in a directory recursively - * - * @param {string} dir - * @returns {Promise} - */ -const getAllFiles = (dir) => - readdir(dir, { withFileTypes: true }).then((dirents) => - Promise.all( - dirents.map((dirent) => { - const res = resolve(dir, dirent.name); - return dirent.isDirectory() ? getAllFiles(res) : res; - }), - ).then((files) => files.flat()), - ); +const Logger = require("./utils/logger"); +const { getAllFiles } = require("./utils/file"); class Controller { + /** + * TODO: scope the key in cameras down so it's just the ones that are defined + * + * TODO: move these into their own thing + * + * @type {{ + * api?: import('./connections/api').APIConnection + * cameras?: import('./connections/cameras').CamerasConnection + * courier?: import('./connections/courier').CourierConnection + * database?: import('./connections/database').DatabaseConnection + * obs?: import('./connections/obs').OBSConnection + * obsBot?: import('./connections/obsbot').OBSBotConnection + * twitch?: import('./connections/twitch').TwitchConnection + * unifi?: import('./connections/unifi').TwitchConnection + * }} + * unifi?: import('./connections/unifi').Unifi + */ #connections = {}; #logger = new Logger("controller"); diff --git a/src/index.js b/src/index.js index 0693cdf..f57bc22 100644 --- a/src/index.js +++ b/src/index.js @@ -1,3 +1,4 @@ +// @ts-check const { join } = require("node:path"); try { @@ -8,11 +9,14 @@ catch (err) { } const Controller = require("./controller"); +const CommandManager = require("./commands"); +const Logger = require("./utils/logger"); // Load any ENV variables from .env file const envFile = process.env.NODE_ENV == 'development' ? `.env.development.local` : '.env'; require('dotenv').config({ path: join(process.cwd(), envFile) }); +const logger = new Logger('index') const main = async () => { // Create our controller object @@ -22,7 +26,13 @@ const main = async () => { await controller.load("./connections"); // Get all our modules - await controller.load("./modules"); + // await controller.load("./modules"); + + if (controller.connections.twitch) { + new CommandManager(controller) + } else { + logger.warn('Twitch connection not found. Twitch chat messages will not be handled.') + } }; main().catch((e) => { diff --git a/src/utils/file.js b/src/utils/file.js new file mode 100644 index 0000000..3a6fc1b --- /dev/null +++ b/src/utils/file.js @@ -0,0 +1,20 @@ +const { readdir } = require("node:fs/promises"); +const { resolve } = require("node:path"); + +/** + * Get all files in a directory recursively + * + * @param {string} dir + * @returns {Promise} + */ +const getAllFiles = (dir) => + readdir(dir, { withFileTypes: true }).then((dirents) => + Promise.all( + dirents.map((dirent) => { + const res = resolve(dir, dirent.name); + return dirent.isDirectory() ? getAllFiles(res) : res; + }), + ).then((files) => files.flat()), + ); + +module.exports = { getAllFiles }