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

Chatbot API #37

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 1 commit
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
4 changes: 2 additions & 2 deletions src/config/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -250,8 +250,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"],
commandVips: ["ptzhome", "ptzpreset", "ptzzoom","ptzzoomr", "ptzload", "ptzlist", "ptzroam", "ptzroaminfo", "ptzfocus", "ptzgetfocus", "ptzfocusr", "ptzautofocus", "ptzgetcam"],
"ptzspeed", "ptzgetspeed", "ptzspin", "ptzcfocus", "ptzfetchimg"],
commandVips: ["ptzhome", "ptzpreset", "ptzzoom","ptzzoomr", "ptzload", "ptzlist", "ptzroam", "ptzroaminfo", "ptzfocus", "ptzgetfocus", "ptzfocusr", "ptzautofocus", "ptzgetcam", "apigetperms"],
commandUsers: []
}
//timeRestrictedCommands = timeRestrictedCommands.concat(["ptzclear"]);
Expand Down
193 changes: 193 additions & 0 deletions src/connections/api.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
require('dotenv').config(); // Load environment variables from .env
pjeweb marked this conversation as resolved.
Show resolved Hide resolved
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
pjeweb marked this conversation as resolved.
Show resolved Hide resolved

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

if (!this.wsUrl) {
this.#logger.error('PUBLIC_WS_URL is not defined in environment variables.');
process.exit(1);
}
pjeweb marked this conversation as resolved.
Show resolved Hide resolved

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');
if (this.controller) {
this.controller.ws = this.#ws; // Ensure controller.ws is set
this.#logger.log('controller.ws assigned');
}
Copy link
Member

Choose a reason for hiding this comment

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

Why are we exposing what is intentionally marked as private in this class? There is already a standard pattern in place for other logic accessing connections from the controller, this violates that pattern?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This was for sendAPI and sendBroadcast, but you make a good point, i will move both into the API class.


// 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;
this.controller.ws = 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 {
const ws = this.controller ? this.controller.currentResponse : null;

if (!ws || ws.readyState !== WebSocket.OPEN) {
this.#logger.error('WebSocket connection not found or closed');
return;
}

try {
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 {
const ws = this.controller ? this.controller.ws : null;

if (!ws || ws.readyState !== WebSocket.OPEN) {
this.#logger.error('WebSocket connection not found or closed');
return;
}

try {
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}`);
}
}
}

// Global error handling
process.on('unhandledRejection', (reason, promise) => {
console.error('Unhandled Rejection at:', promise, 'reason:', reason);
});

process.on('uncaughtException', (error) => {
console.error('Uncaught Exception:', error);
});
Copy link
Member

Choose a reason for hiding this comment

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

Not a fan of adding global stuff into a specific connection file -- this will stop any fatal error across the entire bot from killing the process

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This was same as below, to avoid having the API crash the node, but you are correct not good practice to have global stuff in the connection file, will remove.


// Export a function that instantiates the API class with controller
module.exports = (controller) => {
try {
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
controller.connections.api = new API(wsUrl, wsKey, secretKey, controller);
} catch (error) {
console.error('Failed to instantiate API:', error.message);
}
Copy link
Member

Choose a reason for hiding this comment

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

Similarly here, not sure this should have a try/catch -- if it fails, it should fail

Copy link
Contributor Author

@dansza1 dansza1 Oct 28, 2024

Choose a reason for hiding this comment

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

I think my goal was to make sure nothing with the API takes down the entire chatbot as the API is not critical to the bots functioning. But if its better practice to remove the try/catch we can do that.

};
58 changes: 58 additions & 0 deletions src/connections/cameras.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | null>} Response body, or null if the request failed
* @private
*/
async #getBinary(endpoint) {
try {
const url = `http://${this.#host}${endpoint}`;
console.log(`Fetching binary data from: ${url}`); // Log the request URL
Copy link
Member

Choose a reason for hiding this comment

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

Can this use logger?


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();
console.log('Received ArrayBuffer of size:', data.byteLength); // Log the size of the ArrayBuffer
Copy link
Member

Choose a reason for hiding this comment

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

Can this use logger?

return data;
} catch (e) {
this.#logger.error(`Failed to GET ${endpoint}: ${e.message}`);
return null;
}
}

/**
* Make a POST request to the camera
*
Expand Down Expand Up @@ -132,6 +166,30 @@ class Axis {
return resp !== null;
}

/**
* Fetch an image from the Axis camera
* @returns {Promise<string|null>} 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}`); // Improved error logging
console.error('Error fetching image:', error);
Copy link
Member

Choose a reason for hiding this comment

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

Can this use logger?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes should be logger, will change.

return null; // Return null on error
}
}

/**
* Run a PTZ commands on the camera
*
Expand Down
6 changes: 5 additions & 1 deletion src/connections/twitch.js
Original file line number Diff line number Diff line change
Expand Up @@ -157,8 +157,12 @@ class Twitch {
* @param {string} message Message to send
* @returns {Promise<boolean>} 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());
Expand Down
Loading