Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement commands system #40

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
}
Comment on lines +83 to +93
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of this if, I think you could do await Promise.resolve(command.run(...))?

}
}

/**
* @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') {
Copy link
Member

@MattIPv4 MattIPv4 Jan 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if (typeof command.permission !== 'undefined' && typeof command.permission === 'object') {
if (typeof command.permission !== 'undefined' && typeof command.permission !== 'object') {
  • worth also checking the structure beyond that it is an obj?

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]
Copy link
Member

@MattIPv4 MattIPv4 Jan 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would think we'd want a user to be able to be in multiple groups (so we could have a group for full PTZ commands, a group for just the ptzload etc. commands, and so on)?

I think that might be an easier approach that having an intrinsic ranking to groups?

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 => ({
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the command structure should have an aliases prop (during the command loading you can generate an obj/map to do quick lookups) instead of us needing to create multiple identical objects for each alias (and having a single obj per command means we could more easily use the data to power the site commands list etc.)

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