diff --git a/package-lock.json b/package-lock.json index 974b6dc..f0d0297 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "alveuscontroller", - "version": "0.1.0", + "version": "1.0.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "alveuscontroller", - "version": "0.1.0", + "version": "1.0.0", "license": "UNLICENSED", "dependencies": { "@trycourier/courier": "^4.1.0", @@ -16,11 +16,15 @@ "@twurple/eventsub": "^5.2.7", "digest-fetch": "^2.0.1", "dotenv": "^16.0.3", + "jsonwebtoken": "^9.0.2", + "node-cache": "^5.1.2", "obs-websocket-js": "^5.0.2", "obs-websocket-js-27": "npm:obs-websocket-js@^4.0.3", "on-change": "^4.0.2", "osc": "^2.4.4", - "unifi-client": "^0.11.0" + "reconnecting-websocket": "^4.4.0", + "unifi-client": "^0.11.0", + "ws": "^8.18.0" }, "devDependencies": { "cross-env": "^7.0.3", @@ -668,6 +672,14 @@ "fsevents": "~2.3.2" } }, + "node_modules/clone": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz", + "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==", + "engines": { + "node": ">=0.8" + } + }, "node_modules/combined-stream": { "version": "1.0.8", "license": "MIT", @@ -1553,6 +1565,17 @@ "license": "MIT", "optional": true }, + "node_modules/node-cache": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/node-cache/-/node-cache-5.1.2.tgz", + "integrity": "sha512-t1QzWwnk4sjLWaQAS8CHgOJ+RAfmHpxFWmc36IWTiWHQfs0w5JDMBS1b1ZxQteo0vVVuWJvIUKHDkkeK7vIGCg==", + "dependencies": { + "clone": "2.x" + }, + "engines": { + "node": ">= 8.0.0" + } + }, "node_modules/node-fetch": { "version": "2.6.7", "license": "MIT", @@ -1732,6 +1755,26 @@ "serialport": "10.5.0" } }, + "node_modules/osc/node_modules/ws": { + "version": "8.13.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.13.0.tgz", + "integrity": "sha512-x9vcZYTrFPC7aSIbj7sRCYo7L/Xb8Iy+pW0ng0wt2vCJv7M9HOMy0UoN3rr+IFC7hb7vXoqS+P9ktyLLLhO+LA==", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -1791,6 +1834,11 @@ "node": ">=8.10.0" } }, + "node_modules/reconnecting-websocket": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/reconnecting-websocket/-/reconnecting-websocket-4.4.0.tgz", + "integrity": "sha512-D2E33ceRPga0NvTDhJmphEgJ7FUYF0v4lr1ki0csq06OdlxKfugGzN0dSkxM/NfqCxYELK4KcaTOUOjTV6Dcng==" + }, "node_modules/regexp.prototype.flags": { "version": "1.4.3", "license": "MIT", @@ -2176,8 +2224,9 @@ "license": "Unlicense" }, "node_modules/ws": { - "version": "8.13.0", - "license": "MIT", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", "engines": { "node": ">=10.0.0" }, @@ -2619,6 +2668,11 @@ "readdirp": "~3.6.0" } }, + "clone": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz", + "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==" + }, "combined-stream": { "version": "1.0.8", "requires": { @@ -3191,6 +3245,14 @@ "version": "5.1.0", "optional": true }, + "node-cache": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/node-cache/-/node-cache-5.1.2.tgz", + "integrity": "sha512-t1QzWwnk4sjLWaQAS8CHgOJ+RAfmHpxFWmc36IWTiWHQfs0w5JDMBS1b1ZxQteo0vVVuWJvIUKHDkkeK7vIGCg==", + "requires": { + "clone": "2.x" + } + }, "node-fetch": { "version": "2.6.7", "requires": { @@ -3290,6 +3352,14 @@ "slip": "1.0.2", "wolfy87-eventemitter": "5.2.9", "ws": "8.13.0" + }, + "dependencies": { + "ws": { + "version": "8.13.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.13.0.tgz", + "integrity": "sha512-x9vcZYTrFPC7aSIbj7sRCYo7L/Xb8Iy+pW0ng0wt2vCJv7M9HOMy0UoN3rr+IFC7hb7vXoqS+P9ktyLLLhO+LA==", + "requires": {} + } } }, "path-key": { @@ -3330,6 +3400,11 @@ "picomatch": "^2.2.1" } }, + "reconnecting-websocket": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/reconnecting-websocket/-/reconnecting-websocket-4.4.0.tgz", + "integrity": "sha512-D2E33ceRPga0NvTDhJmphEgJ7FUYF0v4lr1ki0csq06OdlxKfugGzN0dSkxM/NfqCxYELK4KcaTOUOjTV6Dcng==" + }, "regexp.prototype.flags": { "version": "1.4.3", "requires": { @@ -3570,7 +3645,9 @@ "version": "5.2.9" }, "ws": { - "version": "8.13.0", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", "requires": {} }, "yallist": { diff --git a/package.json b/package.json index 88daab6..4b77796 100644 --- a/package.json +++ b/package.json @@ -4,11 +4,11 @@ "description": "alveus livestream chat bot", "main": "src/index.js", "scripts": { - "prettify": "prettier -l --write \"**/*.js\"", + "prettify": "prettier -l --write \"**/*.js\"", "dev": "cross-env NODE_ENV=development node src/index.js", - "dev2": "nodemon --inspect=0.0.0.0:9229 -L src/index.js", + "dev2": "nodemon --inspect=0.0.0.0:9229 -L src/index.js", "test": "node src/test.js", - "start": "node src/index.js" + "start": "node src/index.js" }, "author": "SpaceVoyage", "license": "UNLICENSED", @@ -21,11 +21,15 @@ "@twurple/eventsub": "^5.2.7", "digest-fetch": "^2.0.1", "dotenv": "^16.0.3", + "jsonwebtoken": "^9.0.2", + "node-cache": "^5.1.2", "obs-websocket-js": "^5.0.2", "obs-websocket-js-27": "npm:obs-websocket-js@^4.0.3", "on-change": "^4.0.2", "osc": "^2.4.4", - "unifi-client": "^0.11.0" + "reconnecting-websocket": "^4.4.0", + "unifi-client": "^0.11.0", + "ws": "^8.18.0" }, "prettier": { "trailingComma": "all", @@ -36,7 +40,7 @@ }, "devDependencies": { "cross-env": "^7.0.3", - "prettier": "^3.0.1", - "nodemon": "^3.0.1" + "nodemon": "^3.0.1", + "prettier": "^3.0.1" } } diff --git a/src/config/config.js b/src/config/config.js index f40828f..3e8ed94 100644 --- a/src/config/config.js +++ b/src/config/config.js @@ -260,8 +260,8 @@ const commandPermissionsCamera = { commandMods: ["testmodcamera", "ptztracking", "ptzirlight", "ptzwake"], commandOperator: ["ptzhomeold","ptzseta","ptzgetinfo","ptzset", "ptzpan", "ptztilt", "ptzmove", "ptzir", "ptzdry", "ptzfov", "ptzstop", "ptzsave", "ptzremove", "ptzrename", "ptzcenter", "ptzareazoom", "ptzclick", "ptzdraw", - "ptzspeed", "ptzgetspeed", "ptzspin", "ptzcfocus","ptzplayaudio","ptzstopaudio"], - commandVips: ["ptzhome", "ptzpreset", "ptzzoom","ptzzoomr", "ptzload", "ptzlist", "ptzroam", "ptzroaminfo", "ptzfocus", "ptzgetfocus", "ptzfocusr", "ptzautofocus", "ptzgetcam"], + "ptzspeed", "ptzgetspeed", "ptzspin", "ptzcfocus","ptzplayaudio","ptzstopaudio","ptzfetchimg"], + commandVips: ["ptzhome", "ptzpreset", "ptzzoom","ptzzoomr", "ptzload", "ptzlist", "ptzroam", "ptzroaminfo", "ptzfocus", "ptzgetfocus", "ptzfocusr", "ptzautofocus", "ptzgetcam","apigetperms"], commandUsers: [] } //timeRestrictedCommands = timeRestrictedCommands.concat(["ptzclear"]); diff --git a/src/connections/api.js b/src/connections/api.js new file mode 100644 index 0000000..1d8e89e --- /dev/null +++ b/src/connections/api.js @@ -0,0 +1,176 @@ +const { onTwitchMessage } = require('../modules/legacy'); +const Logger = require('../utils/logger'); +const WebSocket = require('ws'); +const ReconnectingWebSocket = require('reconnecting-websocket'); +const jwt = require('jsonwebtoken'); +const NodeCache = require('node-cache'); // Import node-cache + +class API { + #ws; + #logger; + #pingInterval; + #tokenCache; // Token cache + + constructor(wsUrl, wsKey, secretKey, controller) { + this.wsUrl = `${wsUrl}?token=${wsKey}`; // Append wsKey to the WebSocket URL + this.secretKey = secretKey || 'your-secret-key'; + this.controller = controller; + this.#logger = new Logger("api"); + + // Initialize token cache with a time-to-live of 30 days + this.#tokenCache = new NodeCache({ stdTTL: 30 * 24 * 60 * 60 }); // 30 days in seconds + + this.#createWebSocketConnection(); + } + + // Create WebSocket connection + #createWebSocketConnection() { + try { + if (this.#ws) { + this.#ws.removeAllListeners(); + this.#ws.close(); // Close the existing WebSocket if it exists + } + + this.#ws = new ReconnectingWebSocket(this.wsUrl, [], { WebSocket }); + + this.#ws.addEventListener('open', () => { + this.#logger.log('Connected to the public API server via WebSocket'); + + // Start the ping heartbeat + this.#startHeartbeat(); + }); + + this.#ws.addEventListener('message', async (event) => { + try { + const messageString = event.data.toString('utf8'); + let payload; + + try { + payload = JSON.parse(messageString); + } catch (error) { + this.#logger.error('Failed to parse JSON:', error); + return; + } + + const { token, message: cmdMessage, ...rest } = payload; + let decoded; + + // Check token in cache + const cachedToken = this.#tokenCache.get(token); + if (cachedToken) { + decoded = cachedToken; + } else { + try { + decoded = jwt.verify(token, this.secretKey); + // Cache the decoded token + this.#tokenCache.set(token, decoded); + } catch (error) { + this.#logger.error('Failed to verify JWT:', error); + this.#ws.send(JSON.stringify({ error: 'Invalid or expired token' })); + return; + } + } + + const userName = decoded.userName; + const userId = decoded.userId; + + const tags = { + userInfo: { + userName, + userId + }, + ...rest + }; + + this.controller.currentResponse = this.#ws; + + try { + await onTwitchMessage(this.controller, 'ptzapi', userName, cmdMessage, tags); + this.#ws.send(JSON.stringify({ success: true, message: 'Command processed successfully' })); + } catch (error) { + this.#logger.error(`Error processing command: ${error.message}`); + this.#ws.send(JSON.stringify({ error: 'Failed to process command' })); + } + + } catch (error) { + this.#logger.error(`Unexpected error handling message: ${error.message}`); + } + }); + + this.#ws.addEventListener('close', () => { + this.#logger.log('WebSocket connection to public API server closed'); + }); + + this.#ws.addEventListener('error', (error) => { + this.#logger.error(`WebSocket error: ${error.message}`); + }); + } catch (error) { + this.#logger.error(`Error creating WebSocket connection: ${error.message}`); + } + } + + // Start the ping heartbeat + #startHeartbeat() { + this.#pingInterval = setInterval(() => { + if (this.#ws.readyState === WebSocket.OPEN) { + this.#ws.send(JSON.stringify({ type: 'ping' })); + } + }, 90000); // Send a ping every 90 seconds + } + + // Send API command + async sendAPI(output) { + try { + if (!this.#ws || this.#ws.readyState !== WebSocket.OPEN) { + this.#logger.error('WebSocket connection not found or closed'); + return; + } + + try { + this.#ws.send(JSON.stringify({ message: output })); + this.controller.currentResponse = null; + } catch (error) { + this.#logger.error(`Failed to send response: ${error.message}`); + } + } catch (error) { + this.#logger.error(`Unexpected error in sendAPI: ${error.message}`); + } + } + + // Send broadcast message + async sendBroadcastMessage(message, type = 'frontend') { + try { + if (!this.#ws || this.#ws.readyState !== WebSocket.OPEN) { + this.#logger.error('WebSocket connection not found or closed'); + return; + } + + try { + this.#ws.send(JSON.stringify({ type: type, data: message })); + } catch (error) { + this.#logger.error(`Failed to send broadcast message: ${error.message}`); + } + } catch (error) { + this.#logger.error(`Unexpected error in sendBroadcastMessage: ${error.message}`); + } + } +} + +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 + const wsKey = process.env.WS_SECRET_TOKEN; // Get the WebSocket key token + + // Check if wsUrl is defined before instantiation + if (!wsUrl) { + console.error('PUBLIC_WS_URL is not defined in environment variables.'); + process.exit(1); + } + + try { + controller.connections.api = new API(wsUrl, wsKey, secretKey, controller); + } catch (error) { + console.error('Failed to instantiate API:', error.message); + controller.connections.api = null; // Set to null as a fallback indicator + } +}; diff --git a/src/connections/cameras.js b/src/connections/cameras.js index dd0ab21..24240ca 100644 --- a/src/connections/cameras.js +++ b/src/connections/cameras.js @@ -55,6 +55,40 @@ class Axis { } } + /** + * Make a binary GET request to the camera + * + * @param {string} endpoint Endpoint to make the request to + * @returns {Promise} Response body, or null if the request failed + * @private + */ + async #getBinary(endpoint) { + try { + const url = `http://${this.#host}${endpoint}`; + this.#logger.log(`Fetching binary data from: ${url}`); // Log the request URL + + const response = await this.#client.fetch(url, { + method: 'GET', + headers: { + 'Accept': 'image/jpeg' // Specify the expected content type + } + }); + + if (!response.ok) { + this.#logger.error(`Failed to GET ${endpoint}: ${response.status} ${response.statusText}`); + return null; // Return null if the response is not OK + } + + // Return the response as an ArrayBuffer + const data = await response.arrayBuffer(); + this.#logger.log('Received ArrayBuffer of size:', data.byteLength); // Log the size of the ArrayBuffer + return data; + } catch (e) { + this.#logger.error(`Failed to GET ${endpoint}: ${e.message}`); + return null; + } + } + /** * Make a POST request to the camera * @@ -132,6 +166,29 @@ class Axis { return resp !== null; } + /** + * Fetch an image from the Axis camera + * @returns {Promise} The image as a Base64 string + */ + async fetchImage() { + try { + // Use #getBinary to fetch the image as an ArrayBuffer + const resp = await this.#getBinary('/axis-cgi/jpg/image.cgi'); + + // Check if resp is defined and has data + if (resp && resp.byteLength > 0) { + const base64Image = Buffer.from(resp).toString('base64'); // Convert to Base64 + return base64Image; + } else { + this.#logger.error('Image response is undefined or has no data'); + return null; // Return null if the image fetch was unsuccessful + } + } catch (error) { + this.#logger.error(`Failed to fetch image: ${error.message}`); + return null; // Return null on error + } + } + /** * Run a PTZ commands on the camera * diff --git a/src/connections/twitch.js b/src/connections/twitch.js index d71213e..8520f6d 100644 --- a/src/connections/twitch.js +++ b/src/connections/twitch.js @@ -157,8 +157,12 @@ class Twitch { * @param {string} message Message to send * @returns {Promise} Whether the message was sent successfully */ - async send(channel, message) { + async send(channel, message, api) { try { + // send twitch message to alveusgg channel if api is true + if (api === true) { + channel = 'alveusgg'; + } let messageList = []; if (message.length > 500){ let splitString = message.match(/.{1,480}([\.\s,]|$)/g).map(item => item.trim()); diff --git a/src/modules/legacy.js b/src/modules/legacy.js index d568f07..ca73b6a 100644 --- a/src/modules/legacy.js +++ b/src/modules/legacy.js @@ -70,8 +70,6 @@ const main = async controller => { // } } -module.exports = main; - /** * Handle local OBS scene changes * @@ -356,7 +354,7 @@ const onTwitchMessage = async (controller, channel, user, message, tags) => { let currentScene = controller.connections.obs.local.currentScene || ""; currentScene = helper.cleanName(currentScene); - let parameters = { controller, userCommand, accessProfile, channel, message, currentScene } + let parameters = { controller, userCommand, accessProfile, channel, message, currentScene, user } //check if scene command try { let cloudSceneCommand = await checkLocalSceneCommand(...Object.values(parameters)); @@ -530,7 +528,7 @@ async function checkServerSceneCommand(controller, userCommand, accessProfile, c return sceneCommand; } -async function checkCustomSceneCommand(controller, userCommand, accessProfile, channel, message, currentScene) { +async function checkCustomSceneCommand(controller, userCommand, accessProfile, channel, message, currentScene, user) { userCommand = userCommand || ""; userCommand = userCommand.toLowerCase(); @@ -548,7 +546,7 @@ async function checkCustomSceneCommand(controller, userCommand, accessProfile, c return true; } -async function checkPTZCommand(controller, userCommand, accessProfile, channel, message, currentScene) { +async function checkPTZCommand(controller, userCommand, accessProfile, channel, message, currentScene, user) { //check if PTZ command if (!userCommand.startsWith(config.ptzPrefix)) { return false; @@ -693,10 +691,16 @@ async function checkPTZCommand(controller, userCommand, accessProfile, channel, // logger.log('ptzpan',arg1); camera.panCamera(arg1); camera.enableAutoFocus(); + if (channel === 'ptzapi') { + controller.connections.twitch.send(channel, `${user}: ptzpan ${ptzcamName} ${arg1}`, true); + } break; case "ptztilt": camera.tiltCamera(arg1); camera.enableAutoFocus(); + if (channel === 'ptzapi') { + controller.connections.twitch.send(channel, `${user}: ptztilt ${ptzcamName} ${arg1}`, true); + } break; case "ptzzoom": let zscaledAmount = arg1 * 100 || 0; @@ -765,6 +769,9 @@ async function checkPTZCommand(controller, userCommand, accessProfile, channel, case "ptzmove": camera.moveCamera(arg1); camera.enableAutoFocus(); + if (channel === 'ptzapi') { + controller.connections.twitch.send(channel, `${user}: ptzmove ${ptzcamName} ${arg1}`, true); + } break; case "ptzspeed": if (arg1 != "") { @@ -783,11 +790,17 @@ async function checkPTZCommand(controller, userCommand, accessProfile, channel, //x-cord y-cord rzoom camera.ptz({ center: `${arg1},${arg2}`, rzoom: arg3 }); camera.enableAutoFocus(); + if (channel === 'ptzapi') { + controller.connections.twitch.send(channel, `${user}: Clicked on ${ptzcamName}`, true); + } break; case "ptzareazoom": //x-cord y-cord zoom camera.ptz({ areazoom: `${arg1},${arg2},${arg3}` }); camera.enableAutoFocus(); + if (channel === 'ptzapi') { + controller.connections.twitch.send(channel, `${user}: Clicked on ${ptzcamName}`, true); + } break; case "ptzgetcam": let xCord = parseInt(arg1, 10); @@ -825,7 +838,11 @@ async function checkPTZCommand(controller, userCommand, accessProfile, channel, if (arg4 != 'off') { await camera.enableAutoFocus(); } + if (channel === 'ptzapi') { + controller.connections.twitch.send(channel, `${user} clicked on ${clickbox.zone + 1}: ${clickbox.ptzcamName}`, true); + } else { controller.connections.twitch.send(channel, `Clicked on ${clickbox.zone + 1}: ${clickbox.ptzcamName}`); + } break; case "ptzdraw": // assign user inputs as integers. @@ -848,7 +865,7 @@ async function checkPTZCommand(controller, userCommand, accessProfile, channel, let zoomWidth = drawbox.sourceWidth / scaledRectWidth; let zoomHeight = drawbox.sourceHeight / scaledRectHeight; - zoom = Math.min(zoomWidth, zoomHeight) * 100; + zoom = Math.floor(Math.min(zoomWidth, zoomHeight) * 100); // Set the camera camera = controller.connections.cameras[drawbox.ptzcamName]; @@ -856,11 +873,18 @@ async function checkPTZCommand(controller, userCommand, accessProfile, channel, if (arg5 != 'off') { await camera.enableAutoFocus(); } + if (channel === 'ptzapi') { + controller.connections.twitch.send(channel, `${user} clicked on ${drawbox.zone + 1}: ${drawbox.ptzcamName}`, true); + } else { controller.connections.twitch.send(channel, `Clicked on ${drawbox.zone + 1}: ${drawbox.ptzcamName}`); + } break; case "ptzset": //pan tilt zoom relative pos camera.ptz({ rpan: arg1, rtilt: arg2, rzoom: arg3 * 100, autofocus: "on" }); + if (channel === 'ptzapi') { + controller.connections.twitch.send(channel, `${user}: ptzset ${ptzcamName}`, true); + } break; case "ptzseta": //absolute pos, pan tilt zoom autofocus focus @@ -891,11 +915,18 @@ async function checkPTZCommand(controller, userCommand, accessProfile, channel, customPtz.focus = customFocus; } camera.ptz(customPtz); + if (channel === 'ptzapi') { + controller.connections.twitch.send(channel, `${user}: ptzseta ${ptzcamName}`, true); + } break; case "ptzgetinfo": let cpos = await camera.getPosition(); if (cpos && cpos.pan != null) { - controller.connections.twitch.send(channel, `PTZ Info (${currentScene}): ${cpos.pan}p |${cpos.tilt}t |${cpos.zoom}z |af ${cpos.autofocus || "n/a"} |${cpos.focus || "n/a"}f`); + if (channel === 'ptzapi') { + controller.connections.api.sendAPI(`PTZ Info (${currentScene}): ${cpos.pan}p |${cpos.tilt}t |${cpos.zoom}z |af ${cpos.autofocus || "n/a"} |${cpos.focus || "n/a"}f`); + } else { + controller.connections.twitch.send(channel, `PTZ Info (${currentScene}): ${cpos.pan}p |${cpos.tilt}t |${cpos.zoom}z |af ${cpos.autofocus || "n/a"} |${cpos.focus || "n/a"}f`); + } } else { logger.log("Failed to get ptz position"); } @@ -917,6 +948,9 @@ async function checkPTZCommand(controller, userCommand, accessProfile, channel, } else { camera.setIRCutFilter("auto"); } + if (channel === 'ptzapi') { + controller.connections.twitch.send(channel, `${user}: ptzir ${ptzcamName} ${arg1}`, true); + } break; case "ptzirlight": if (arg1 == "1" || arg1 == "on" || arg1 == "yes") { @@ -927,6 +961,30 @@ async function checkPTZCommand(controller, userCommand, accessProfile, channel, camera.disableIR(); } break; + case "ptzfetchimg": + if (channel === 'ptzapi') { + // Fetch the screenshot + const imageBase64 = await camera.fetchImage(); + if (imageBase64) { + // Prepare the message + const message = { + image: imageBase64, + camera: specificCamera || currentScene, + preset: arg1, // Include preset name + user: user + }; + + // Send the message to the API websocket. + controller.connections.api.sendBroadcastMessage(message, 'image'); + logger.log("Sending image"); + } else { + logger.log("Failed to fetch image after saving preset"); + } + + } else { + return; + } + break; case "ptzsave": let currentPosition = await camera.getPosition(); if (currentPosition && currentPosition.pan != null) { @@ -946,6 +1004,23 @@ async function checkPTZCommand(controller, userCommand, accessProfile, channel, controller.connections.database[currentScene].lastKnownPosition = currentPosition; } } + controller.connections.api.sendBroadcastMessage(`ptzsave ${specificCamera} ${arg1}`, 'backend'); + + // Fetch the screenshot after saving the preset + const imageBase64 = await camera.fetchImage(); + if (imageBase64) { + // Prepare the message + const message = { + image: imageBase64, + camera: specificCamera || currentScene, + preset: arg1 // Include preset name + }; + + // Send the message to the API websocket. + controller.connections.api.sendBroadcastMessage(message, 'image'); + } else { + logger.log("Failed to fetch image after saving preset"); + } } else { logger.log("Failed to get ptz position"); @@ -997,6 +1072,9 @@ async function checkPTZCommand(controller, userCommand, accessProfile, channel, camera.ptz({ pan: previous.pan, tilt: previous.tilt, zoom: previous.zoom }); } } + if (channel === 'ptzapi') { + controller.connections.twitch.send(channel, `${user}: ptzload ${specificCamera} ${arg1}`, true); + } break; case "ptzremove": if (specificCamera != "") { @@ -1005,6 +1083,9 @@ async function checkPTZCommand(controller, userCommand, accessProfile, channel, //2nd argument provided if (controller.connections.database[specificCamera].presets[arg1] != null) { let response = delete controller.connections.database[specificCamera].presets[arg1]; + if (response === true) { + controller.connections.api.sendBroadcastMessage(`ptzremove ${specificCamera} ${arg1}`, 'backend'); + } if (response != true) { logger.log(`Failed to remove preset ${arg1}: ${response} ${controller.connections.database[specificCamera]}`); } @@ -1029,6 +1110,9 @@ async function checkPTZCommand(controller, userCommand, accessProfile, channel, if (controller.connections.database[specificCamera].presets[arg1] != null) { controller.connections.database[specificCamera].presets[arg2] = controller.connections.database[specificCamera].presets[arg1]; let response = delete controller.connections.database[specificCamera].presets[arg1]; + if (response === true) { + controller.connections.api.sendBroadcastMessage(`ptzrename ${specificCamera} ${arg1} ${arg2}`, 'backend'); + } if (response != true) { logger.log(`Failed to remove preset ${arg1}: ${response} ${controller.connections.database[specificCamera]}`); } @@ -1061,11 +1145,18 @@ async function checkPTZCommand(controller, userCommand, accessProfile, channel, break; case "ptzlist": if (specificCamera) { - controller.connections.twitch.send(channel, `PTZ Presets: ${Object.keys(controller.connections.database[specificCamera].presets).sort().toString()}`) + if (channel === 'ptzapi') { + controller.connections.api.sendAPI(`PTZ Presets: ${Object.keys(controller.connections.database[specificCamera].presets).sort().toString()}`); + } else { + controller.connections.twitch.send(channel, `PTZ Presets: ${Object.keys(controller.connections.database[specificCamera].presets).sort().toString()}`); + } } else { - controller.connections.twitch.send(channel, `PTZ Presets: ${Object.keys(controller.connections.database[currentScene].presets).sort().toString()}`) + if (channel === 'ptzapi') { + controller.connections.api.sendAPI(`PTZ Presets: ${Object.keys(controller.connections.database[currentScene].presets).sort().toString()}`); + } else { + controller.connections.twitch.send(channel, `PTZ Presets: ${Object.keys(controller.connections.database[currentScene].presets).sort().toString()}`); + } } - break; case "ptzgetfocus": let pos = await camera.getPosition(); @@ -1077,7 +1168,6 @@ async function checkPTZCommand(controller, userCommand, accessProfile, channel, } catch (e) { //logger.log("Error getting focus") } - } break; case "ptzroam": @@ -1348,7 +1438,7 @@ async function checkNuthouseCommand(controller, userCommand, accessProfile, chan return true; } -async function checkCustomCamCommand(controller, userCommand, accessProfile, channel, message, currentScene) { +async function checkCustomCamCommand(controller, userCommand, accessProfile, channel, message, currentScene, user) { if (userCommand == null || currentScene != "custom") { return false; } @@ -1430,7 +1520,7 @@ async function checkCustomCamCommand(controller, userCommand, accessProfile, cha return true; } -async function checkExtraCommand(controller, userCommand, accessProfile, channel, message, currentScene) { +async function checkExtraCommand(controller, userCommand, accessProfile, channel, message, currentScene, user) { //extra message = message.trim(); let messageArgs = message.split(" "); @@ -1518,6 +1608,9 @@ async function checkExtraCommand(controller, userCommand, accessProfile, channel camname = config.customCommandAlias[camname] || camname; // allow for cam name aliases camname = "fullcam " + camname; controller.connections.obs.local.restartSceneItem(controller.connections.obs.local.currentScene, camname); + if (channel === 'ptzapi') { + controller.connections.twitch.send(channel, `${user}: resetcam ${camname}`, true); + } break; case "setalveusscene": controller.connections.obs.local.setScene(fullArgs); @@ -1759,7 +1852,24 @@ async function checkExtraCommand(controller, userCommand, accessProfile, channel switchToCustomCams(controller, channel, accessProfile, userCommand, fullArgs); } break; + case "apigetperms": + if (channel === 'ptzapi') { + // Check the user's permissions + for (let group of config.userPermissions.commandPriority) { + // Check if the user exists in the corresponding command group array + if (config.userPermissions[group].includes(user)) { + // Send the API response + controller.connections.api.sendAPI(`${user} has ${group} permissions`); + break; + } + } + } + break; case "scenecams": + if (currentScene != "custom") { + return false; + } + let output = ""; if (arg1 == "json") { output = JSON.stringify(currentCamList); @@ -1781,7 +1891,31 @@ async function checkExtraCommand(controller, userCommand, accessProfile, channel } } - controller.connections.twitch.send(channel, output) + const sceneCommand = controller.connections.database["customcamscommand"]; + + // Create finaloutput with both scene and cams + const finaloutput = { + scene: sceneCommand, + cams: output + }; + + // Determine the correct output format and send it + if (arg1 == "json" || arg1 == "jsonmap") { + // For JSON formats, stringify the finaloutput + if (channel === 'ptzapi') { + controller.connections.api.sendAPI(JSON.stringify(finaloutput)); + } else { + controller.connections.twitch.send(channel, JSON.stringify(finaloutput)); + } + } else { + // For plain text or other formats, manually format and include both scene and cams + const formattedOutput = `Scene: ${finaloutput.scene} \nCams: ${finaloutput.cams}`; + if (channel === 'ptzapi') { + controller.connections.api.sendAPI(formattedOutput); + } else { + controller.connections.twitch.send(channel, formattedOutput); + } + } break; case "swapcam": if (currentScene != "custom") { @@ -1869,6 +2003,10 @@ async function checkExtraCommand(controller, userCommand, accessProfile, channel logger.log(`Swap Cam ${cam1} to ${cam2} - fullargs: ${fullArgs}`); switchToCustomCams(controller, channel, accessProfile, userCommand, fullArgs); + + if (channel === 'ptzapi') { + controller.connections.twitch.send(channel, `${user}: Swap ${arg1} ${arg2}`, true); + } } break; // case "nightcams": @@ -1955,6 +2093,9 @@ async function checkExtraCommand(controller, userCommand, accessProfile, channel controller.connections.obs.cloud.setInputVolume(config.globalMusicSource, scaledVol2); } } + if (channel === 'ptzapi') { + controller.connections.twitch.send(channel, `${user}: setvolume ${arg1} ${arg2}`, true); + } break; case "getvolume": if (arg1 == "" || arg1 == "all") { @@ -2002,7 +2143,11 @@ async function checkExtraCommand(controller, userCommand, accessProfile, channel } // logger.log('getvolume all',output); - controller.connections.twitch.send(channel, `Volumes: ${output}`) + if (channel === 'ptzapi') { + controller.connections.api.sendAPI(`Volumes: ${output}`) + } else { + controller.connections.twitch.send(channel, `Volumes: ${output}`) + } } else { let volName = arg1Clean; if (arg1 == "mic" || arg1 == "mics" || arg1 == "cam" || arg1 == "cams") { @@ -2026,7 +2171,11 @@ async function checkExtraCommand(controller, userCommand, accessProfile, channel if (isMuted) { muteStatus = "m"; } - controller.connections.twitch.send(channel, `Music Volume: ${correctedVol.toFixed(1).replace(/[.,]0$/, "")}${muteStatus}`) + if (channel === 'ptzapi') { + controller.connections.api.sendAPI(channel, `Music Volume: ${correctedVol.toFixed(1).replace(/[.,]0$/, "")}${muteStatus}`) + } else { + controller.connections.twitch.send(channel, `Music Volume: ${correctedVol.toFixed(1).replace(/[.,]0$/, "")}${muteStatus}`) + } } } else { let dbVolume = await controller.connections.obs.local.getInputVolume(audioSource); @@ -2040,7 +2189,11 @@ async function checkExtraCommand(controller, userCommand, accessProfile, channel muteStatus = "m"; } // logger.log("Setting",correctedVol); - controller.connections.twitch.send(channel, `Volume: ${volName} - ${correctedVol.toFixed(1).replace(/[.,]0$/, "")}${muteStatus}`) + if (channel === 'ptzapi') { + controller.connections.api.sendAPI(channel, `Volume: ${volName} - ${correctedVol.toFixed(1).replace(/[.,]0$/, "")}${muteStatus}`) + } else { + controller.connections.twitch.send(channel, `Volume: ${volName} - ${correctedVol.toFixed(1).replace(/[.,]0$/, "")}${muteStatus}`) + } } } } @@ -2127,6 +2280,7 @@ function clearCustomCamsDB(controller) { async function switchToCustomCams(controller, channel, accessProfile, userCommand, fullArgs) { console.log("switch", channel, accessProfile, userCommand, fullArgs); + let obsSources = await controller.connections.obs.local.getSceneItemList("Custom Cams") || []; //controller.connections.obs.local.sceneList || []; let obsList = []; let currentCamList = []; @@ -2324,6 +2478,13 @@ async function switchToCustomCams(controller, channel, accessProfile, userComman } controller.connections.database["customcamscommand"] = userCommand; } + // Send cam switch info to ptz-alv + const broadcastMessage = { + userCommand: userCommand, + fullArgs: fullArgs + }; + controller.connections.api.sendBroadcastMessage(broadcastMessage); + } async function setCustomCams(controller, obsSources, sceneName, camList, toggleMap, positions) { @@ -2556,3 +2717,5 @@ function runAtSpecificTimeOfDay(hour, minutes, func) { setInterval(func, twentyFourHours); }, eta_ms); } +module.exports = Object.assign(main, { onTwitchMessage }); +