-
-
Notifications
You must be signed in to change notification settings - Fork 8
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
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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') { | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
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] | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
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 => ({ | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
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; |
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> | ||
} |
There was a problem hiding this comment.
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(...))
?