feat: Initialize project with core dependencies, Vultr API client, customer database service, and migration documentation.
This commit is contained in:
185
services/customerDb.js
Normal file
185
services/customerDb.js
Normal file
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* Customer Database Module - JavaScript Wrapper for Python Implementation
|
||||
*
|
||||
* This module provides the same API as the original customerDb.js,
|
||||
* but calls Python functions via command line interface.
|
||||
*/
|
||||
|
||||
const { execFile } = require('child_process');
|
||||
const { promisify } = require('util');
|
||||
const path = require('path');
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
// Determine Python path - prefer virtual environment python if available
|
||||
const PYTHON_PATH = process.env.PYTHON_PATH ||
|
||||
(process.platform === 'win32' ? 'python' : 'python3');
|
||||
|
||||
// Path to Python module
|
||||
const PYTHON_MODULE_PATH = path.join(__dirname, '..', 'src', 'services', 'customer_db.py');
|
||||
|
||||
/**
|
||||
* Call Python function with arguments
|
||||
* @param {string} functionName - Name of the Python function to call
|
||||
* @param {Object} args - Arguments for the function
|
||||
* @returns {Promise<any>} - Result from Python function
|
||||
*/
|
||||
async function callPythonFunction(functionName, args = {}) {
|
||||
try {
|
||||
// Build command line arguments
|
||||
const cliArgs = ['--function', functionName];
|
||||
|
||||
// Add function-specific arguments
|
||||
if (functionName === 'getCustomerByPhone' && args.phone) {
|
||||
cliArgs.push('--phone', args.phone);
|
||||
} else if (functionName === 'searchCustomer' && args.query) {
|
||||
cliArgs.push('--query', args.query);
|
||||
if (args.limit) {
|
||||
cliArgs.push('--limit', args.limit.toString());
|
||||
}
|
||||
} else if (functionName === 'getSearchCount' && args.query) {
|
||||
cliArgs.push('--query', args.query);
|
||||
} else if (functionName === 'executeReadOnlyQuery' && args.sql) {
|
||||
cliArgs.push('--sql', args.sql);
|
||||
}
|
||||
|
||||
// Add timeout
|
||||
cliArgs.push('--timeout', '30');
|
||||
|
||||
// Execute Python script
|
||||
const { stdout, stderr } = await execFileAsync(PYTHON_PATH, [PYTHON_MODULE_PATH, ...cliArgs], {
|
||||
cwd: path.join(__dirname, '..'),
|
||||
env: { ...process.env, PYTHONUNBUFFERED: '1' },
|
||||
timeout: 35000, // 35 seconds timeout
|
||||
maxBuffer: 10 * 1024 * 1024, // 10MB buffer for large results
|
||||
});
|
||||
|
||||
if (stderr && stderr.trim()) {
|
||||
console.warn(`Python stderr: ${stderr}`);
|
||||
}
|
||||
|
||||
// Parse JSON output
|
||||
const result = JSON.parse(stdout.trim());
|
||||
|
||||
// Handle Python errors
|
||||
if (result && result.error) {
|
||||
console.error(`Python function ${functionName} error:`, result.error);
|
||||
throw new Error(result.error);
|
||||
}
|
||||
|
||||
return result;
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Error calling Python function ${functionName}:`, error);
|
||||
|
||||
// Provide more helpful error messages
|
||||
if (error.code === 'ENOENT') {
|
||||
throw new Error(`Python not found at ${PYTHON_PATH}. Make sure Python 3.8+ is installed and in PATH.`);
|
||||
} else if (error.code === 'ETIMEDOUT' || error.signal === 'SIGTERM') {
|
||||
throw new Error(`Python function ${functionName} timed out after 35 seconds.`);
|
||||
} else if (error instanceof SyntaxError) {
|
||||
throw new Error(`Invalid JSON response from Python: ${error.message}`);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test database connection
|
||||
* @returns {Promise<boolean>} - True if connection successful
|
||||
*/
|
||||
async function testConnection() {
|
||||
try {
|
||||
const result = await callPythonFunction('testConnection');
|
||||
return result === true;
|
||||
} catch (error) {
|
||||
console.error('Database connection test failed:', error.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get customer by phone number with format normalization
|
||||
* @param {string} phoneNumber - Phone number to search for
|
||||
* @returns {Promise<Object|null>} - Customer object or null if not found
|
||||
*/
|
||||
async function getCustomerByPhone(phoneNumber) {
|
||||
try {
|
||||
const result = await callPythonFunction('getCustomerByPhone', { phone: phoneNumber });
|
||||
return result || null;
|
||||
} catch (error) {
|
||||
console.error('getCustomerByPhone error:', error.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search customers by name, phone, status, or profile
|
||||
* @param {string} query - Search term
|
||||
* @param {number} limit - Maximum results (default: 5)
|
||||
* @returns {Promise<Array>} - Array of customer objects
|
||||
*/
|
||||
async function searchCustomer(query, limit = 5) {
|
||||
try {
|
||||
const result = await callPythonFunction('searchCustomer', { query, limit });
|
||||
return Array.isArray(result) ? result : [];
|
||||
} catch (error) {
|
||||
console.error('searchCustomer error:', error.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get customer count grouped by status
|
||||
* @returns {Promise<Array>} - Array of status count objects
|
||||
*/
|
||||
async function getSummary() {
|
||||
try {
|
||||
const result = await callPythonFunction('getSummary');
|
||||
return Array.isArray(result) ? result : [];
|
||||
} catch (error) {
|
||||
console.error('getSummary error:', error.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Count customers matching search query
|
||||
* @param {string} query - Search term
|
||||
* @returns {Promise<number>} - Count of matching customers
|
||||
*/
|
||||
async function getSearchCount(query) {
|
||||
try {
|
||||
const result = await callPythonFunction('getSearchCount', { query });
|
||||
return typeof result === 'number' ? result : 0;
|
||||
} catch (error) {
|
||||
console.error('getSearchCount error:', error.message);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a read-only SQL query with security checks
|
||||
* @param {string} sql - SELECT SQL query to execute
|
||||
* @returns {Promise<Array|Object>} - Query results or error object
|
||||
*/
|
||||
async function executeReadOnlyQuery(sql) {
|
||||
try {
|
||||
const result = await callPythonFunction('executeReadOnlyQuery', { sql });
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('executeReadOnlyQuery error:', error.message);
|
||||
return { error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
// Export functions with same API as original
|
||||
module.exports = {
|
||||
getCustomerByPhone,
|
||||
searchCustomer,
|
||||
getSummary,
|
||||
getSearchCount,
|
||||
testConnection,
|
||||
executeReadOnlyQuery,
|
||||
};
|
||||
127
services/loggingService.js
Normal file
127
services/loggingService.js
Normal file
@@ -0,0 +1,127 @@
|
||||
const db = require('../db_config');
|
||||
|
||||
class LoggingService {
|
||||
async logMessage(source, target, content, direction, hasMedia = false, status = 'SENT', errorLog = null) {
|
||||
try {
|
||||
console.log(`[LoggingService] Logging ${direction} message from ${source} to ${target}`);
|
||||
const query = `
|
||||
INSERT INTO messages_log (source_number, target_number, message_content, direction, has_media, status, error_log)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`;
|
||||
const params = [source, target, content, direction, hasMedia, status, errorLog];
|
||||
await db.execute(query, params);
|
||||
} catch (error) {
|
||||
console.error('[LoggingService] Failed to log message:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async saveContact(phone, name, savedName, isMyContact) {
|
||||
try {
|
||||
const query = `
|
||||
INSERT INTO contacts (phone, name, saved_name, is_my_contact)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
name = VALUES(name),
|
||||
saved_name = VALUES(saved_name),
|
||||
is_my_contact = VALUES(is_my_contact),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
`;
|
||||
await db.execute(query, [phone, name, savedName, isMyContact]);
|
||||
} catch (error) {
|
||||
console.error('[LoggingService] Failed to save contact:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async getMessages(fromFilter = null, limit = 100) {
|
||||
try {
|
||||
let query = `
|
||||
SELECT m.*, c.name, c.saved_name
|
||||
FROM messages_log m
|
||||
LEFT JOIN contacts c ON m.source_number = c.phone
|
||||
`;
|
||||
const params = [];
|
||||
|
||||
if (fromFilter) {
|
||||
// Optimization: Remove leading % effectively uses the index.
|
||||
// Assuming user searches "852..." or full number.
|
||||
// If they search "52", it won't match "0852", which is acceptable for performance.
|
||||
query += " WHERE m.source_number LIKE ? ";
|
||||
params.push(`${fromFilter}%`);
|
||||
}
|
||||
|
||||
query += " ORDER BY m.timestamp DESC LIMIT ?";
|
||||
params.push(limit);
|
||||
|
||||
const [rows] = await db.execute(query, params);
|
||||
return rows;
|
||||
} catch (error) {
|
||||
console.error('[LoggingService] Failed to get messages:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async queueMessage(target, message) {
|
||||
try {
|
||||
const query = `INSERT INTO message_queue (target_number, message) VALUES (?, ?)`;
|
||||
const [result] = await db.execute(query, [target, message]);
|
||||
return result.insertId;
|
||||
} catch (error) {
|
||||
console.error('[LoggingService] Failed to queue message:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getPendingQueue() {
|
||||
try {
|
||||
const [rows] = await db.execute("SELECT * FROM message_queue WHERE status = 'pending' ORDER BY id ASC LIMIT 1");
|
||||
return rows[0];
|
||||
} catch (error) {
|
||||
console.error('[LoggingService] Failed to get pending queue:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async updateQueueStatus(id, status) {
|
||||
try {
|
||||
await db.execute("UPDATE message_queue SET status = ? WHERE id = ?", [status, id]);
|
||||
} catch (error) {
|
||||
console.error('[LoggingService] Failed to update queue:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async getQueue() {
|
||||
try {
|
||||
const [rows] = await db.execute("SELECT * FROM message_queue ORDER BY id DESC LIMIT 100");
|
||||
return rows;
|
||||
} catch (error) {
|
||||
console.error('[LoggingService] Failed to get queue:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// New Method for Contextual Chat History
|
||||
async getConversationHistory(userPhone, botPhone, limit = 10) {
|
||||
try {
|
||||
// Get last N messages between user and bot
|
||||
const query = `
|
||||
SELECT source_number, message_content, direction, timestamp
|
||||
FROM messages_log
|
||||
WHERE (source_number = ? AND target_number = ?)
|
||||
OR (source_number = ? AND target_number = ?)
|
||||
ORDER BY timestamp DESC LIMIT ?
|
||||
`;
|
||||
const [rows] = await db.execute(query, [userPhone, botPhone, botPhone, userPhone, limit]);
|
||||
|
||||
// Return reversed (oldest first) for proper context flow
|
||||
return rows.reverse().map(r => ({
|
||||
role: r.source_number === userPhone ? 'user' : 'model',
|
||||
message: r.message_content
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('[LoggingService] Failed to get history:', error);
|
||||
return []; // Fail gracefully with empty history
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new LoggingService();
|
||||
207
services/mikrotikService.js
Normal file
207
services/mikrotikService.js
Normal file
@@ -0,0 +1,207 @@
|
||||
const axios = require('axios');
|
||||
const https = require('https');
|
||||
|
||||
class MikrotikService {
|
||||
constructor() {
|
||||
this.client = null;
|
||||
}
|
||||
|
||||
getConfiguredRouterIds() {
|
||||
const ids = [1]; // Default router always exists (or assumed ID 1)
|
||||
// Scan env for MIKROTIK_HOST_X
|
||||
for (const key in process.env) {
|
||||
if (key.startsWith('MIKROTIK_HOST_')) {
|
||||
const id = parseInt(key.replace('MIKROTIK_HOST_', ''));
|
||||
if (!isNaN(id) && !ids.includes(id)) {
|
||||
ids.push(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
getConfig(routerId = 1) {
|
||||
|
||||
// Default to Router 1 if not specified or if routerId is 1.
|
||||
// If routerId is provided (e.g. 3), look for MIKROTIK_HOST_3
|
||||
let host;
|
||||
|
||||
if (routerId && routerId != 1) {
|
||||
// STRICT MODE: Secondary routers MUST have their own config.
|
||||
// Do NOT fall back to main router, or we'll connect to the wrong device.
|
||||
host = process.env[`MIKROTIK_HOST_${routerId}`];
|
||||
if (!host) {
|
||||
throw new Error(`Configuration for Router ${routerId} (MIKROTIK_HOST_${routerId}) not found in .env`);
|
||||
}
|
||||
} else {
|
||||
// Default Router 1
|
||||
host = process.env.MIKROTIK_HOST;
|
||||
if (!host) throw new Error("Mikrotik Host (Router 1) not configured in .env");
|
||||
}
|
||||
|
||||
const suffix = (routerId && routerId != 1) ? `_${routerId}` : '';
|
||||
|
||||
// Only fallback to default credentials if specifically Router 1.
|
||||
// For Router 3+, we expect specific creds OR we can allow fallback for creds but NOT host.
|
||||
// Actually, safer to rely on suffix or default global ONLY if logic permits.
|
||||
// Let's allow fallback for User/Pass/Port (common to reuse creds) but Host was the critical one.
|
||||
const user = process.env[`MIKROTIK_USER${suffix}`] || process.env.MIKROTIK_USER || 'admin';
|
||||
const password = process.env[`MIKROTIK_PASSWORD${suffix}`] || process.env.MIKROTIK_PASSWORD || '';
|
||||
let port = parseInt(process.env[`MIKROTIK_PORT${suffix}`] || process.env.MIKROTIK_PORT || 80);
|
||||
if (port === 8728) port = 80;
|
||||
|
||||
return {
|
||||
host,
|
||||
user,
|
||||
password,
|
||||
port,
|
||||
protocol: (port === 443) ? 'https' : 'http'
|
||||
};
|
||||
}
|
||||
|
||||
getClient(routerId = 1) {
|
||||
const config = this.getConfig(routerId);
|
||||
const baseURL = `${config.protocol}://${config.host}:${config.port}/rest`;
|
||||
|
||||
return axios.create({
|
||||
baseURL,
|
||||
auth: {
|
||||
username: config.user,
|
||||
password: config.password
|
||||
},
|
||||
httpsAgent: new https.Agent({
|
||||
rejectUnauthorized: false
|
||||
}),
|
||||
timeout: 10000
|
||||
});
|
||||
}
|
||||
|
||||
async execute(method, url, data = null, routerId = 1) {
|
||||
try {
|
||||
const client = this.getClient(routerId);
|
||||
const response = await client.request({
|
||||
method,
|
||||
url,
|
||||
data
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
if (error.response) {
|
||||
// throw new Error(`Mikrotik API Error: ${error.response.status} ${JSON.stringify(error.response.data)}`);
|
||||
// Return empty for 404 on list calls?
|
||||
throw new Error(`Mikrotik Error (Router ${routerId}): ${error.response.status} - ${JSON.stringify(error.response.data)}`);
|
||||
} else if (error.request) {
|
||||
console.error(`[Mikrotik Router ${routerId} No Response]`, error.message);
|
||||
throw new Error(`Mikrotik Router ${routerId} Unreachable`);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async getSystemResource(routerId = 1) {
|
||||
const data = await this.execute('GET', '/system/resource', null, routerId);
|
||||
return Array.isArray(data) ? data[0] : data;
|
||||
}
|
||||
|
||||
// --- HOTSPOT ---
|
||||
async getHotspotUsers(routerId = 1) {
|
||||
return this.execute('GET', '/ip/hotspot/user', null, routerId);
|
||||
}
|
||||
|
||||
async getActiveHotspot(routerId = 1) {
|
||||
return this.execute('GET', '/ip/hotspot/active', null, routerId);
|
||||
}
|
||||
|
||||
async getActiveUsers(routerId = 1) { // Alias for consistency
|
||||
return this.getActiveHotspot(routerId);
|
||||
}
|
||||
|
||||
async enableUser(name, routerId = 1) {
|
||||
const users = await this.execute('GET', `/ip/hotspot/user?name=${encodeURIComponent(name)}`, null, routerId);
|
||||
if (!users || users.length === 0) throw new Error(`User ${name} not found on Router ${routerId}`);
|
||||
const id = users[0]['.id'];
|
||||
await this.execute('PATCH', `/ip/hotspot/user/${id}`, { disabled: "false" }, routerId);
|
||||
}
|
||||
|
||||
async disableUser(name, routerId = 1) {
|
||||
const users = await this.execute('GET', `/ip/hotspot/user?name=${encodeURIComponent(name)}`, null, routerId);
|
||||
if (!users || users.length === 0) throw new Error(`User ${name} not found on Router ${routerId}`);
|
||||
const id = users[0]['.id'];
|
||||
await this.execute('PATCH', `/ip/hotspot/user/${id}`, { disabled: "true" }, routerId);
|
||||
// Kick
|
||||
try {
|
||||
const active = await this.execute('GET', `/ip/hotspot/active?user=${encodeURIComponent(name)}`, null, routerId);
|
||||
if (active && active.length > 0) {
|
||||
await this.execute('DELETE', `/ip/hotspot/active/${active[0]['.id']}`, null, routerId);
|
||||
}
|
||||
} catch (e) { }
|
||||
}
|
||||
|
||||
|
||||
// --- PPPoE ---
|
||||
async getPppoeSecrets(routerId = 1) {
|
||||
return this.execute('GET', '/ppp/secret', null, routerId);
|
||||
}
|
||||
|
||||
async getActivePppoe(routerId = 1) {
|
||||
return this.execute('GET', '/ppp/active', null, routerId);
|
||||
}
|
||||
|
||||
async disablePppoeUser(name, routerId = 1) {
|
||||
const users = await this.execute('GET', `/ppp/secret?name=${encodeURIComponent(name)}`, null, routerId);
|
||||
if (!users || users.length === 0) throw new Error(`User ${name} not found on Router ${routerId}`);
|
||||
const id = users[0]['.id'];
|
||||
await this.execute('PATCH', `/ppp/secret/${id}`, { disabled: "true" }, routerId);
|
||||
|
||||
try {
|
||||
const active = await this.execute('GET', `/ppp/active?name=${encodeURIComponent(name)}`, null, routerId);
|
||||
if (active && active.length > 0) {
|
||||
await this.execute('DELETE', `/ppp/active/${active[0]['.id']}`, null, routerId);
|
||||
}
|
||||
} catch (e) { }
|
||||
return true;
|
||||
}
|
||||
|
||||
async enablePppoeUser(name, routerId = 1) {
|
||||
const users = await this.execute('GET', `/ppp/secret?name=${encodeURIComponent(name)}`, null, routerId);
|
||||
if (!users || users.length === 0) throw new Error(`User ${name} not found on Router ${routerId}`);
|
||||
const id = users[0]['.id'];
|
||||
await this.execute('PATCH', `/ppp/secret/${id}`, { disabled: "false" }, routerId);
|
||||
return true;
|
||||
}
|
||||
|
||||
async getPppoeSecret(name, routerId = 1) {
|
||||
const users = await this.execute('GET', `/ppp/secret?name=${encodeURIComponent(name)}`, null, routerId);
|
||||
return (users && users.length > 0) ? users[0] : null;
|
||||
}
|
||||
|
||||
|
||||
async getInterfaces(routerId = 1) {
|
||||
return this.execute('GET', '/interface', null, routerId);
|
||||
}
|
||||
|
||||
async monitorInterfaceTraffic(interfaceName, routerId = 1) {
|
||||
const result = await this.execute('POST', '/interface/monitor-traffic', {
|
||||
interface: interfaceName,
|
||||
once: 'true'
|
||||
}, routerId);
|
||||
return Array.isArray(result) ? result[0] : result;
|
||||
}
|
||||
|
||||
async findInterface(partialName, routerId = 1) {
|
||||
const interfaces = await this.getInterfaces(routerId);
|
||||
const exact = interfaces.find(i => i.name === partialName);
|
||||
if (exact) return exact;
|
||||
|
||||
const cleanInput = partialName.toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||
const fuzzy = interfaces.find(i => {
|
||||
const cleanName = i.name.toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||
return cleanName.includes(cleanInput);
|
||||
});
|
||||
|
||||
return fuzzy || null;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new MikrotikService();
|
||||
Reference in New Issue
Block a user