feat: Initialize project with core dependencies, Vultr API client, customer database service, and migration documentation.

This commit is contained in:
2026-02-06 18:11:28 +08:00
parent 73366f4395
commit 41c2fa5fcc
2618 changed files with 852274 additions and 1449 deletions

185
services/customerDb.js Normal file
View 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
View 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
View 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();