Skip to content

Commit

Permalink
Implement commands system
Browse files Browse the repository at this point in the history
Closes alveusgg#32
Closes alveusgg#33
Closes alveusgg#34

Signed-off-by: flakey5 <[email protected]>
  • Loading branch information
flakey5 committed Jan 2, 2025
1 parent 3c24907 commit 5878f25
Show file tree
Hide file tree
Showing 18 changed files with 591 additions and 21 deletions.
171 changes: 171 additions & 0 deletions src/commands/index.js
Original file line number Diff line number Diff line change
@@ -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<string, import('./types.d.ts').Command>}
*/
#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<void>}
*/
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
59 changes: 59 additions & 0 deletions src/commands/ptzplayaudio.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
/**
* @type {Record<string, number>}
*/
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<import('./types.d.ts').Command>}
*/
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
27 changes: 27 additions & 0 deletions src/commands/ptzstopaudio.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
const aliases = [
'ptzplayaudio',
'playclip',
'playaudio'
]

/**
* @type {Array<import('./types.d.ts').Command>}
*/
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;
36 changes: 36 additions & 0 deletions src/commands/types.d.ts
Original file line number Diff line number Diff line change
@@ -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<string>
}

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<void>
}
4 changes: 2 additions & 2 deletions src/config/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
Loading

0 comments on commit 5878f25

Please sign in to comment.