topsapp: mostra Circuit-ID do cliente

Signed-off-by: Rodrigo Martins <rodrigo@martinsevieira.adv.br>
This commit is contained in:
Rodrigo Martins committed 2026-04-04 23:23:42 -03:00
1 parent d8badf1fde
commit 01f1315f9e
34 files changed
+70789 -70768

No files matched your search

+8130 -8115
View File
File diff suppressed because it is too large. Load diff
+24 -24
View File
@@ -1,24 +1,24 @@
module.exports = {
telegram: {
token: process.env.TELEGRAM_BOT_TOKEN || ''
},
ssh: {
defaultHost: process.env.DEFAULT_SSH_HOST || '',
defaultUser: process.env.DEFAULT_SSH_USER || '',
defaultPassword: process.env.DEFAULT_SSH_PASSWORD || '',
timeout: 30000
},
bot: {
debug: process.env.BOT_DEBUG === 'true',
allowedUsers: process.env.ALLOWED_USERS ?
process.env.ALLOWED_USERS.split(',').map(id => parseInt(id.trim())) :
[]
},
commands: {
start: '/start',
help: '/help',
ssh: '/ssh',
status: '/status',
equipamentos: '/equipamentos'
}
};
module.exports = {
telegram: {
token: process.env.TELEGRAM_BOT_TOKEN || ''
},
ssh: {
defaultHost: process.env.DEFAULT_SSH_HOST || '',
defaultUser: process.env.DEFAULT_SSH_USER || '',
defaultPassword: process.env.DEFAULT_SSH_PASSWORD || '',
timeout: 30000
},
bot: {
debug: process.env.BOT_DEBUG === 'true',
allowedUsers: process.env.ALLOWED_USERS ?
process.env.ALLOWED_USERS.split(',').map(id => parseInt(id.trim())) :
[]
},
commands: {
start: '/start',
help: '/help',
ssh: '/ssh',
status: '/status',
equipamentos: '/equipamentos'
}
};
+1076 -1076
View File
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+328 -328
View File
@@ -1,328 +1,328 @@
const axios = require('axios');
const GenieACSConfig = require('./config');
const logger = require('../utils/logger');
/**
* Parâmetros TR-069 que serão buscados para o comando "ver roteador".
* A projeção é enviada como query string para a NBI do GenieACS.
*/
const DEVICE_PROJECTION = [
'_tags',
'Tags',
'DeviceID.SerialNumber',
'DeviceID.ProductClass',
'DeviceID.Manufacturer',
'DeviceID.OUI',
'InternetGatewayDevice.DeviceInfo.SoftwareVersion',
'InternetGatewayDevice.DeviceInfo.HardwareVersion',
// Wi-Fi
'InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.SSID',
'InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.AutoChannelEnable',
'InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.Channel',
'InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.TotalAssociations',
'InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.PreSharedKey.1.KeyPassphrase',
'InternetGatewayDevice.LANDevice.1.WLANConfiguration.2.SSID',
'InternetGatewayDevice.LANDevice.1.WLANConfiguration.2.AutoChannelEnable',
'InternetGatewayDevice.LANDevice.1.WLANConfiguration.2.Channel',
'InternetGatewayDevice.LANDevice.1.WLANConfiguration.2.TotalAssociations',
'InternetGatewayDevice.LANDevice.1.WLANConfiguration.2.PreSharedKey.1.KeyPassphrase',
// PPPoE WAN
'InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANPPPConnection.1.Username',
'InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANPPPConnection.1.ConnectionStatus',
'InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANPPPConnection.1.ExternalIPAddress',
'InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANPPPConnection.1.LastConnectionError',
'InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANPPPConnection.1.DNSServers',
'InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANPPPConnection.1.DefaultGateway',
// Link físico e tráfego
'InternetGatewayDevice.WANDevice.1.WANCommonInterfaceConfig.PhysicalLinkStatus',
'VirtualParameters.pppoe_uptime',
'VirtualParameters.trafego_recebido',
'VirtualParameters.trafego_enviado',
'VirtualParameters.taxa_download',
'VirtualParameters.taxa_upload',
// DHCP LAN
'InternetGatewayDevice.LANDevice.1.LANHostConfigManagement.MinAddress',
'InternetGatewayDevice.LANDevice.1.LANHostConfigManagement.MaxAddress',
'InternetGatewayDevice.LANDevice.1.LANHostConfigManagement.DNSServers',
// IP LAN
'InternetGatewayDevice.LANDevice.1.LANHostConfigManagement.IPInterface.1.IPInterfaceIPAddress',
'InternetGatewayDevice.LANDevice.1.LANHostConfigManagement.IPInterface.1.IPInterfaceSubnetMask'
].join(',');
class GenieACSApi {
constructor() {
this.config = new GenieACSConfig();
}
/**
* Monta um cliente axios com Basic Auth para uma instância.
* @param {Object} instance - objeto de instância do GenieACSConfig
*/
_buildClient(instance) {
return axios.create({
baseURL: this.config.getNBIUrl(instance),
timeout: 30000,
auth: {
username: instance.user,
password: instance.password
}
});
}
/**
* Navega por um objeto aninhado usando um caminho separado por ponto
* e retorna o campo _value do nó folha, conforme o formato de resposta
* da NBI do GenieACS.
*
* Exemplo: getNestedValue(device, 'DeviceID.SerialNumber') retorna o valor
* de device.DeviceID.SerialNumber._value
*
* @param {Object} obj - Objeto de dados do dispositivo
* @param {string} path - Caminho com pontos (ex.: 'DeviceID.SerialNumber')
* @returns {*} - O valor encontrado ou null
*/
_getNestedValue(obj, path) {
if (!obj) return null;
const parts = path.split('.');
let current = obj;
for (const part of parts) {
if (current === null || current === undefined) return null;
current = current[part];
}
// O GenieACS armazena o valor em _value no nó folha
if (current && typeof current === 'object' && '_value' in current) {
const v = current._value;
// Tratar valores vazios/nulos como ausentes (retorna null para acionar fallback)
return (v !== null && v !== undefined && v !== '') ? v : null;
}
// Valor direto (string, número, booleano)
if (current !== null && current !== undefined && typeof current !== 'object') {
return String(current).trim() !== '' ? current : null;
}
return null;
}
/**
* Executa a busca HTTP na NBI com um query object MongoDB-like.
* Método interno reutilizável.
*
* @param {Object} queryObj - Objeto de filtro no formato GenieACS
* @param {Object} instance - Instância de GenieACSConfig
* @returns {Promise<Object>} - { success, device, rawDevice, searchedBy, error }
*/
async _buscar(queryObj, instance, searchedBy) {
const client = this._buildClient(instance);
try {
const response = await client.get('/devices', {
params: {
query: JSON.stringify(queryObj),
projection: DEVICE_PROJECTION
}
});
const devices = response.data;
if (!Array.isArray(devices) || devices.length === 0) {
return { success: false };
}
const raw = devices[0];
const device = this._parseDevice(raw);
return { success: true, device, rawDevice: raw, searchedBy };
} catch (error) {
const status = error.response?.status;
const msg = status
? `Erro HTTP ${status} ao consultar GenieACS (${instance.name})`
: `Falha de conexão com GenieACS (${instance.name}): ${error.message}`;
logger.error(`[GenieACS] ${msg}`);
return { success: false, error: msg };
}
}
/**
* Busca um dispositivo por serial number.
* Tenta exato primeiro; se não encontrar, tenta uppercase (usuário pode ter digitado minúsculo).
* @param {string} serial - Serial number
* @param {Object} instance - Instância de GenieACSConfig
*/
async buscarDispositivo(serial, instance) {
logger.info(`[GenieACS] Buscando serial "${serial}" em ${instance.name}`);
// Tentativa 1: exato como foi informado
const r1 = await this._buscar({ 'DeviceID.SerialNumber': serial }, instance, 'serial');
if (r1.success) return r1;
// Tentativa 2: uppercase (seriais costumam ser armazenados em maiúsculas)
const upper = serial.toUpperCase();
if (upper !== serial) {
logger.info(`[GenieACS] Tentando serial em uppercase: "${upper}"`);
return this._buscar({ 'DeviceID.SerialNumber': upper }, instance, 'serial');
}
return r1;
}
/**
* Busca um dispositivo pela TAG (nome de usuário TopSapp).
* Usa regex case-insensitive para não depender de maiúsculas/minúsculas.
* @param {string} tag - Valor da tag (ex.: login PPPoE do cliente)
* @param {Object} instance - Instância de GenieACSConfig
*/
async buscarPorTag(tag, instance) {
logger.info(`[GenieACS] Buscando por TAG "${tag}" em ${instance.name}`);
// Escapa caracteres especiais de regex no valor da tag
const tagEscaped = tag.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
// Tentativa 1: campo _tags com regex case-insensitive (formato array)
const r1 = await this._buscar(
{ '_tags': { '$regex': `^${tagEscaped}$`, '$options': 'i' } },
instance, 'tag'
);
if (r1.success) return r1;
// Tentativa 2: campo Tags.<nome> = true (formato objeto, como exibido na UI)
logger.info(`[GenieACS] Tentando campo Tags.${tag} em ${instance.name}`);
return this._buscar({ [`Tags.${tag.toLowerCase()}`]: true }, instance, 'tag');
}
/**
* Cria uma task setParameterValues no dispositivo via NBI.
*
* O parâmetro `connection_request` na URL instrui o GenieACS a enviar um
* TR-069 Connection Request ao CPE imediatamente, triggering um inform.
* Sem ele, o CPE só executaria a task no próximo inform agendado.
* O `timeout` (ms) define por quanto tempo o GenieACS aguarda o CPE conectar
* antes de retornar 202 (task enfileirada mas ainda não executada).
*
* @param {string} deviceId - _id do dispositivo
* @param {Array} parameterValues - Array de [paramName, value, xsdType]
* @param {Object} instance - Instância de GenieACSConfig
* @returns {Promise<Object>} - { success, queued, error }
*/
async setParameterValues(deviceId, parameterValues, instance) {
const client = this._buildClient(instance);
const encodedId = encodeURIComponent(deviceId);
try {
const response = await client.post(
`/devices/${encodedId}/tasks?connection_request`,
{ name: 'setParameterValues', parameterValues },
{ params: { timeout: 15000 } }
);
const queued = response.status === 202;
logger.info(`[GenieACS] setParameterValues em ${deviceId} (${queued ? 'enfileirado' : 'aplicado'}): ${JSON.stringify(parameterValues)}`);
return { success: true, queued };
} catch (error) {
const status = error.response?.status;
const msg = status
? `Erro HTTP ${status} ao gravar parâmetros em ${deviceId} (${instance.name})`
: `Falha ao gravar parâmetros: ${error.message}`;
logger.error(`[GenieACS] ${msg}`);
return { success: false, error: msg };
}
}
/**
* Adiciona uma TAG a um dispositivo na NBI do GenieACS.
* Endpoint: POST /devices/{id}/tags/{tag}
*
* @param {string} deviceId - _id do dispositivo (ex.: "00E0FC-Huawei-VMXQU22716608217")
* @param {string} tag - Tag a adicionar (ex.: "clickcolmeia2025")
* @param {Object} instance - Instância de GenieACSConfig
* @returns {Promise<Object>} - { success, error }
*/
async adicionarTag(deviceId, tag, instance) {
const client = this._buildClient(instance);
const encodedId = encodeURIComponent(deviceId);
const encodedTag = encodeURIComponent(tag);
try {
await client.post(`/devices/${encodedId}/tags/${encodedTag}`);
logger.info(`[GenieACS] TAG "${tag}" adicionada ao device ${deviceId}`);
return { success: true };
} catch (error) {
const status = error.response?.status;
const msg = status
? `Erro HTTP ${status} ao adicionar TAG "${tag}" (${instance.name})`
: `Falha ao adicionar TAG: ${error.message}`;
logger.error(`[GenieACS] ${msg}`);
return { success: false, error: msg };
}
}
/**
* Extrai os campos relevantes de um dispositivo bruto retornado pela NBI.
* @param {Object} raw - Objeto bruto do GenieACS
* @returns {Object} - Objeto formatado com os campos de interesse
*/
_parseDevice(raw) {
const get = (path) => this._getNestedValue(raw, path);
// O _id do GenieACS segue o formato "OUI-ProductClass-SerialNumber".
// Usado como fallback quando os campos DeviceID.* não chegam via projeção
// ou estão em formato diferente do esperado.
const idParts = typeof raw._id === 'string' ? raw._id.split('-') : [];
const idOUI = idParts[0] || null;
const idProductClass = idParts[1] || null;
// Serial é tudo a partir da 3ª parte (pode conter hifens)
const idSerial = idParts.length >= 3 ? idParts.slice(2).join('-') : null;
const autoChannel24 = get('InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.AutoChannelEnable');
const autoChannel5 = get('InternetGatewayDevice.LANDevice.1.WLANConfiguration.2.AutoChannelEnable');
// _tags é um array direto na raiz do objeto (não segue o padrão _value)
// Tags também pode vir como objeto { clickcolmeia2025: { _value: true } } (formato UI do GenieACS)
let tags = [];
if (Array.isArray(raw._tags)) {
tags = raw._tags;
} else if (raw.Tags && typeof raw.Tags === 'object') {
// Extrai as chaves do objeto Tags onde o valor é true
tags = Object.entries(raw.Tags)
.filter(([k, v]) => k !== '_timestamp' && (v === true || (v && v._value === true)))
.map(([k]) => k);
}
return {
deviceId : raw._id || null,
tags : tags,
serial : get('DeviceID.SerialNumber') ?? idSerial,
productClass : get('DeviceID.ProductClass') ?? idProductClass,
manufacturer : get('DeviceID.Manufacturer') ?? idProductClass,
oui : get('DeviceID.OUI') ?? idOUI,
softwareVersion : get('InternetGatewayDevice.DeviceInfo.SoftwareVersion'),
hardwareVersion : get('InternetGatewayDevice.DeviceInfo.HardwareVersion'),
// Wi-Fi
ssid24 : get('InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.SSID'),
autoChannel24 : autoChannel24 === true || autoChannel24 === 'true' || autoChannel24 === '1' ? 'Sim' : 'Não',
channel24 : get('InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.Channel'),
clients24 : get('InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.TotalAssociations'),
senha24 : get('InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.PreSharedKey.1.KeyPassphrase'),
ssid5 : get('InternetGatewayDevice.LANDevice.1.WLANConfiguration.2.SSID'),
autoChannel5 : autoChannel5 === true || autoChannel5 === 'true' || autoChannel5 === '1' ? 'Sim' : 'Não',
channel5 : get('InternetGatewayDevice.LANDevice.1.WLANConfiguration.2.Channel'),
clients5 : get('InternetGatewayDevice.LANDevice.1.WLANConfiguration.2.TotalAssociations'),
senha5 : get('InternetGatewayDevice.LANDevice.1.WLANConfiguration.2.PreSharedKey.1.KeyPassphrase'),
// PPPoE WAN
pppoeUsername : get('InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANPPPConnection.1.Username'),
pppoeStatus : get('InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANPPPConnection.1.ConnectionStatus'),
pppoeIP : get('InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANPPPConnection.1.ExternalIPAddress'),
pppoeUptime : get('VirtualParameters.pppoe_uptime'),
pppoeLastError : get('InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANPPPConnection.1.LastConnectionError'),
dnsWAN : get('InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANPPPConnection.1.DNSServers'),
gatewayWAN : get('InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANPPPConnection.1.DefaultGateway'),
// Link físico e tráfego
linkFisicoWAN : get('InternetGatewayDevice.WANDevice.1.WANCommonInterfaceConfig.PhysicalLinkStatus'),
trafegoRecebido : get('VirtualParameters.trafego_recebido'),
trafegoEnviado : get('VirtualParameters.trafego_enviado'),
taxaDownload : get('VirtualParameters.taxa_download'),
taxaUpload : get('VirtualParameters.taxa_upload'),
// DHCP LAN
dhcpInicio : get('InternetGatewayDevice.LANDevice.1.LANHostConfigManagement.MinAddress'),
dhcpFim : get('InternetGatewayDevice.LANDevice.1.LANHostConfigManagement.MaxAddress'),
dnsLAN : get('InternetGatewayDevice.LANDevice.1.LANHostConfigManagement.DNSServers'),
ipLAN : get('InternetGatewayDevice.LANDevice.1.LANHostConfigManagement.IPInterface.1.IPInterfaceIPAddress'),
mascaraLAN : get('InternetGatewayDevice.LANDevice.1.LANHostConfigManagement.IPInterface.1.IPInterfaceSubnetMask')
};
}
}
module.exports = GenieACSApi;
const axios = require('axios');
const GenieACSConfig = require('./config');
const logger = require('../utils/logger');
/**
* Parâmetros TR-069 que serão buscados para o comando "ver roteador".
* A projeção é enviada como query string para a NBI do GenieACS.
*/
const DEVICE_PROJECTION = [
'_tags',
'Tags',
'DeviceID.SerialNumber',
'DeviceID.ProductClass',
'DeviceID.Manufacturer',
'DeviceID.OUI',
'InternetGatewayDevice.DeviceInfo.SoftwareVersion',
'InternetGatewayDevice.DeviceInfo.HardwareVersion',
// Wi-Fi
'InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.SSID',
'InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.AutoChannelEnable',
'InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.Channel',
'InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.TotalAssociations',
'InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.PreSharedKey.1.KeyPassphrase',
'InternetGatewayDevice.LANDevice.1.WLANConfiguration.2.SSID',
'InternetGatewayDevice.LANDevice.1.WLANConfiguration.2.AutoChannelEnable',
'InternetGatewayDevice.LANDevice.1.WLANConfiguration.2.Channel',
'InternetGatewayDevice.LANDevice.1.WLANConfiguration.2.TotalAssociations',
'InternetGatewayDevice.LANDevice.1.WLANConfiguration.2.PreSharedKey.1.KeyPassphrase',
// PPPoE WAN
'InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANPPPConnection.1.Username',
'InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANPPPConnection.1.ConnectionStatus',
'InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANPPPConnection.1.ExternalIPAddress',
'InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANPPPConnection.1.LastConnectionError',
'InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANPPPConnection.1.DNSServers',
'InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANPPPConnection.1.DefaultGateway',
// Link físico e tráfego
'InternetGatewayDevice.WANDevice.1.WANCommonInterfaceConfig.PhysicalLinkStatus',
'VirtualParameters.pppoe_uptime',
'VirtualParameters.trafego_recebido',
'VirtualParameters.trafego_enviado',
'VirtualParameters.taxa_download',
'VirtualParameters.taxa_upload',
// DHCP LAN
'InternetGatewayDevice.LANDevice.1.LANHostConfigManagement.MinAddress',
'InternetGatewayDevice.LANDevice.1.LANHostConfigManagement.MaxAddress',
'InternetGatewayDevice.LANDevice.1.LANHostConfigManagement.DNSServers',
// IP LAN
'InternetGatewayDevice.LANDevice.1.LANHostConfigManagement.IPInterface.1.IPInterfaceIPAddress',
'InternetGatewayDevice.LANDevice.1.LANHostConfigManagement.IPInterface.1.IPInterfaceSubnetMask'
].join(',');
class GenieACSApi {
constructor() {
this.config = new GenieACSConfig();
}
/**
* Monta um cliente axios com Basic Auth para uma instância.
* @param {Object} instance - objeto de instância do GenieACSConfig
*/
_buildClient(instance) {
return axios.create({
baseURL: this.config.getNBIUrl(instance),
timeout: 30000,
auth: {
username: instance.user,
password: instance.password
}
});
}
/**
* Navega por um objeto aninhado usando um caminho separado por ponto
* e retorna o campo _value do nó folha, conforme o formato de resposta
* da NBI do GenieACS.
*
* Exemplo: getNestedValue(device, 'DeviceID.SerialNumber') retorna o valor
* de device.DeviceID.SerialNumber._value
*
* @param {Object} obj - Objeto de dados do dispositivo
* @param {string} path - Caminho com pontos (ex.: 'DeviceID.SerialNumber')
* @returns {*} - O valor encontrado ou null
*/
_getNestedValue(obj, path) {
if (!obj) return null;
const parts = path.split('.');
let current = obj;
for (const part of parts) {
if (current === null || current === undefined) return null;
current = current[part];
}
// O GenieACS armazena o valor em _value no nó folha
if (current && typeof current === 'object' && '_value' in current) {
const v = current._value;
// Tratar valores vazios/nulos como ausentes (retorna null para acionar fallback)
return (v !== null && v !== undefined && v !== '') ? v : null;
}
// Valor direto (string, número, booleano)
if (current !== null && current !== undefined && typeof current !== 'object') {
return String(current).trim() !== '' ? current : null;
}
return null;
}
/**
* Executa a busca HTTP na NBI com um query object MongoDB-like.
* Método interno reutilizável.
*
* @param {Object} queryObj - Objeto de filtro no formato GenieACS
* @param {Object} instance - Instância de GenieACSConfig
* @returns {Promise<Object>} - { success, device, rawDevice, searchedBy, error }
*/
async _buscar(queryObj, instance, searchedBy) {
const client = this._buildClient(instance);
try {
const response = await client.get('/devices', {
params: {
query: JSON.stringify(queryObj),
projection: DEVICE_PROJECTION
}
});
const devices = response.data;
if (!Array.isArray(devices) || devices.length === 0) {
return { success: false };
}
const raw = devices[0];
const device = this._parseDevice(raw);
return { success: true, device, rawDevice: raw, searchedBy };
} catch (error) {
const status = error.response?.status;
const msg = status
? `Erro HTTP ${status} ao consultar GenieACS (${instance.name})`
: `Falha de conexão com GenieACS (${instance.name}): ${error.message}`;
logger.error(`[GenieACS] ${msg}`);
return { success: false, error: msg };
}
}
/**
* Busca um dispositivo por serial number.
* Tenta exato primeiro; se não encontrar, tenta uppercase (usuário pode ter digitado minúsculo).
* @param {string} serial - Serial number
* @param {Object} instance - Instância de GenieACSConfig
*/
async buscarDispositivo(serial, instance) {
logger.info(`[GenieACS] Buscando serial "${serial}" em ${instance.name}`);
// Tentativa 1: exato como foi informado
const r1 = await this._buscar({ 'DeviceID.SerialNumber': serial }, instance, 'serial');
if (r1.success) return r1;
// Tentativa 2: uppercase (seriais costumam ser armazenados em maiúsculas)
const upper = serial.toUpperCase();
if (upper !== serial) {
logger.info(`[GenieACS] Tentando serial em uppercase: "${upper}"`);
return this._buscar({ 'DeviceID.SerialNumber': upper }, instance, 'serial');
}
return r1;
}
/**
* Busca um dispositivo pela TAG (nome de usuário TopSapp).
* Usa regex case-insensitive para não depender de maiúsculas/minúsculas.
* @param {string} tag - Valor da tag (ex.: login PPPoE do cliente)
* @param {Object} instance - Instância de GenieACSConfig
*/
async buscarPorTag(tag, instance) {
logger.info(`[GenieACS] Buscando por TAG "${tag}" em ${instance.name}`);
// Escapa caracteres especiais de regex no valor da tag
const tagEscaped = tag.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
// Tentativa 1: campo _tags com regex case-insensitive (formato array)
const r1 = await this._buscar(
{ '_tags': { '$regex': `^${tagEscaped}$`, '$options': 'i' } },
instance, 'tag'
);
if (r1.success) return r1;
// Tentativa 2: campo Tags.<nome> = true (formato objeto, como exibido na UI)
logger.info(`[GenieACS] Tentando campo Tags.${tag} em ${instance.name}`);
return this._buscar({ [`Tags.${tag.toLowerCase()}`]: true }, instance, 'tag');
}
/**
* Cria uma task setParameterValues no dispositivo via NBI.
*
* O parâmetro `connection_request` na URL instrui o GenieACS a enviar um
* TR-069 Connection Request ao CPE imediatamente, triggering um inform.
* Sem ele, o CPE só executaria a task no próximo inform agendado.
* O `timeout` (ms) define por quanto tempo o GenieACS aguarda o CPE conectar
* antes de retornar 202 (task enfileirada mas ainda não executada).
*
* @param {string} deviceId - _id do dispositivo
* @param {Array} parameterValues - Array de [paramName, value, xsdType]
* @param {Object} instance - Instância de GenieACSConfig
* @returns {Promise<Object>} - { success, queued, error }
*/
async setParameterValues(deviceId, parameterValues, instance) {
const client = this._buildClient(instance);
const encodedId = encodeURIComponent(deviceId);
try {
const response = await client.post(
`/devices/${encodedId}/tasks?connection_request`,
{ name: 'setParameterValues', parameterValues },
{ params: { timeout: 15000 } }
);
const queued = response.status === 202;
logger.info(`[GenieACS] setParameterValues em ${deviceId} (${queued ? 'enfileirado' : 'aplicado'}): ${JSON.stringify(parameterValues)}`);
return { success: true, queued };
} catch (error) {
const status = error.response?.status;
const msg = status
? `Erro HTTP ${status} ao gravar parâmetros em ${deviceId} (${instance.name})`
: `Falha ao gravar parâmetros: ${error.message}`;
logger.error(`[GenieACS] ${msg}`);
return { success: false, error: msg };
}
}
/**
* Adiciona uma TAG a um dispositivo na NBI do GenieACS.
* Endpoint: POST /devices/{id}/tags/{tag}
*
* @param {string} deviceId - _id do dispositivo (ex.: "00E0FC-Huawei-VMXQU22716608217")
* @param {string} tag - Tag a adicionar (ex.: "clickcolmeia2025")
* @param {Object} instance - Instância de GenieACSConfig
* @returns {Promise<Object>} - { success, error }
*/
async adicionarTag(deviceId, tag, instance) {
const client = this._buildClient(instance);
const encodedId = encodeURIComponent(deviceId);
const encodedTag = encodeURIComponent(tag);
try {
await client.post(`/devices/${encodedId}/tags/${encodedTag}`);
logger.info(`[GenieACS] TAG "${tag}" adicionada ao device ${deviceId}`);
return { success: true };
} catch (error) {
const status = error.response?.status;
const msg = status
? `Erro HTTP ${status} ao adicionar TAG "${tag}" (${instance.name})`
: `Falha ao adicionar TAG: ${error.message}`;
logger.error(`[GenieACS] ${msg}`);
return { success: false, error: msg };
}
}
/**
* Extrai os campos relevantes de um dispositivo bruto retornado pela NBI.
* @param {Object} raw - Objeto bruto do GenieACS
* @returns {Object} - Objeto formatado com os campos de interesse
*/
_parseDevice(raw) {
const get = (path) => this._getNestedValue(raw, path);
// O _id do GenieACS segue o formato "OUI-ProductClass-SerialNumber".
// Usado como fallback quando os campos DeviceID.* não chegam via projeção
// ou estão em formato diferente do esperado.
const idParts = typeof raw._id === 'string' ? raw._id.split('-') : [];
const idOUI = idParts[0] || null;
const idProductClass = idParts[1] || null;
// Serial é tudo a partir da 3ª parte (pode conter hifens)
const idSerial = idParts.length >= 3 ? idParts.slice(2).join('-') : null;
const autoChannel24 = get('InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.AutoChannelEnable');
const autoChannel5 = get('InternetGatewayDevice.LANDevice.1.WLANConfiguration.2.AutoChannelEnable');
// _tags é um array direto na raiz do objeto (não segue o padrão _value)
// Tags também pode vir como objeto { clickcolmeia2025: { _value: true } } (formato UI do GenieACS)
let tags = [];
if (Array.isArray(raw._tags)) {
tags = raw._tags;
} else if (raw.Tags && typeof raw.Tags === 'object') {
// Extrai as chaves do objeto Tags onde o valor é true
tags = Object.entries(raw.Tags)
.filter(([k, v]) => k !== '_timestamp' && (v === true || (v && v._value === true)))
.map(([k]) => k);
}
return {
deviceId : raw._id || null,
tags : tags,
serial : get('DeviceID.SerialNumber') ?? idSerial,
productClass : get('DeviceID.ProductClass') ?? idProductClass,
manufacturer : get('DeviceID.Manufacturer') ?? idProductClass,
oui : get('DeviceID.OUI') ?? idOUI,
softwareVersion : get('InternetGatewayDevice.DeviceInfo.SoftwareVersion'),
hardwareVersion : get('InternetGatewayDevice.DeviceInfo.HardwareVersion'),
// Wi-Fi
ssid24 : get('InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.SSID'),
autoChannel24 : autoChannel24 === true || autoChannel24 === 'true' || autoChannel24 === '1' ? 'Sim' : 'Não',
channel24 : get('InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.Channel'),
clients24 : get('InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.TotalAssociations'),
senha24 : get('InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.PreSharedKey.1.KeyPassphrase'),
ssid5 : get('InternetGatewayDevice.LANDevice.1.WLANConfiguration.2.SSID'),
autoChannel5 : autoChannel5 === true || autoChannel5 === 'true' || autoChannel5 === '1' ? 'Sim' : 'Não',
channel5 : get('InternetGatewayDevice.LANDevice.1.WLANConfiguration.2.Channel'),
clients5 : get('InternetGatewayDevice.LANDevice.1.WLANConfiguration.2.TotalAssociations'),
senha5 : get('InternetGatewayDevice.LANDevice.1.WLANConfiguration.2.PreSharedKey.1.KeyPassphrase'),
// PPPoE WAN
pppoeUsername : get('InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANPPPConnection.1.Username'),
pppoeStatus : get('InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANPPPConnection.1.ConnectionStatus'),
pppoeIP : get('InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANPPPConnection.1.ExternalIPAddress'),
pppoeUptime : get('VirtualParameters.pppoe_uptime'),
pppoeLastError : get('InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANPPPConnection.1.LastConnectionError'),
dnsWAN : get('InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANPPPConnection.1.DNSServers'),
gatewayWAN : get('InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANPPPConnection.1.DefaultGateway'),
// Link físico e tráfego
linkFisicoWAN : get('InternetGatewayDevice.WANDevice.1.WANCommonInterfaceConfig.PhysicalLinkStatus'),
trafegoRecebido : get('VirtualParameters.trafego_recebido'),
trafegoEnviado : get('VirtualParameters.trafego_enviado'),
taxaDownload : get('VirtualParameters.taxa_download'),
taxaUpload : get('VirtualParameters.taxa_upload'),
// DHCP LAN
dhcpInicio : get('InternetGatewayDevice.LANDevice.1.LANHostConfigManagement.MinAddress'),
dhcpFim : get('InternetGatewayDevice.LANDevice.1.LANHostConfigManagement.MaxAddress'),
dnsLAN : get('InternetGatewayDevice.LANDevice.1.LANHostConfigManagement.DNSServers'),
ipLAN : get('InternetGatewayDevice.LANDevice.1.LANHostConfigManagement.IPInterface.1.IPInterfaceIPAddress'),
mascaraLAN : get('InternetGatewayDevice.LANDevice.1.LANHostConfigManagement.IPInterface.1.IPInterfaceSubnetMask')
};
}
}
module.exports = GenieACSApi;
+78 -78
View File
@@ -1,78 +1,78 @@
/**
* Configurações das instâncias GenieACS
* Suporta múltiplas instâncias para expansão futura.
*
* Variáveis de ambiente esperadas (prefix GENIEACS_<CHAVE>_*):
* GENIEACS_GUARAI_URL, GENIEACS_GUARAI_NBI_PORT,
* GENIEACS_GUARAI_USER, GENIEACS_GUARAI_PASS
*/
class GenieACSConfig {
constructor() {
// Instâncias conhecidas – novas podem ser adicionadas aqui
// ou via variáveis de ambiente com o mesmo padrão de chave.
this.instances = {
guarai: {
name: 'Guarai - Clicknet',
baseURL: process.env.GENIEACS_GUARAI_URL || 'http://g.clicknetto.com.br',
nbiPort: parseInt(process.env.GENIEACS_GUARAI_NBI_PORT, 10) || 7557,
user: process.env.GENIEACS_GUARAI_USER || '',
password: process.env.GENIEACS_GUARAI_PASS || ''
}
/*
* Exemplo de nova instância futura:
* pedro_afonso: {
* name: 'Pedro Afonso - Clicknet',
* baseURL: process.env.GENIEACS_PEDRO_AFONSO_URL || '',
* nbiPort: parseInt(process.env.GENIEACS_PEDRO_AFONSO_NBI_PORT, 10) || 7557,
* user: process.env.GENIEACS_PEDRO_AFONSO_USER || '',
* password: process.env.GENIEACS_PEDRO_AFONSO_PASS || ''
* }
*/
};
}
/**
* Retorna a instância padrão (guarai).
*/
getDefaultInstance() {
return this.instances.guarai;
}
/**
* Retorna uma instância pelo nome da chave.
* Se a chave não existir, retorna a instância padrão.
*/
getInstanceByKey(key) {
return this.instances[key] || this.getDefaultInstance();
}
/**
* Seleciona a instância correta com base em um texto de localização livre.
* Útil quando o bot suportar múltiplas instâncias.
*/
getInstanceByLocation(location = '') {
const loc = location.toLowerCase();
if (loc.includes('pedro') || loc.includes('afonso')) {
return this.instances.pedro_afonso || this.getDefaultInstance();
}
return this.getDefaultInstance();
}
/**
* Verifica se ao menos uma instância está configurada (user + password).
*/
isReady() {
return Object.values(this.instances).some(
(inst) => inst.user && inst.password
);
}
/**
* Retorna a URL NBI completa de uma instância.
*/
getNBIUrl(instance) {
return `${instance.baseURL}:${instance.nbiPort}`;
}
}
module.exports = GenieACSConfig;
/**
* Configurações das instâncias GenieACS
* Suporta múltiplas instâncias para expansão futura.
*
* Variáveis de ambiente esperadas (prefix GENIEACS_<CHAVE>_*):
* GENIEACS_GUARAI_URL, GENIEACS_GUARAI_NBI_PORT,
* GENIEACS_GUARAI_USER, GENIEACS_GUARAI_PASS
*/
class GenieACSConfig {
constructor() {
// Instâncias conhecidas – novas podem ser adicionadas aqui
// ou via variáveis de ambiente com o mesmo padrão de chave.
this.instances = {
guarai: {
name: 'Guarai - Clicknet',
baseURL: process.env.GENIEACS_GUARAI_URL || 'http://g.clicknetto.com.br',
nbiPort: parseInt(process.env.GENIEACS_GUARAI_NBI_PORT, 10) || 7557,
user: process.env.GENIEACS_GUARAI_USER || '',
password: process.env.GENIEACS_GUARAI_PASS || ''
}
/*
* Exemplo de nova instância futura:
* pedro_afonso: {
* name: 'Pedro Afonso - Clicknet',
* baseURL: process.env.GENIEACS_PEDRO_AFONSO_URL || '',
* nbiPort: parseInt(process.env.GENIEACS_PEDRO_AFONSO_NBI_PORT, 10) || 7557,
* user: process.env.GENIEACS_PEDRO_AFONSO_USER || '',
* password: process.env.GENIEACS_PEDRO_AFONSO_PASS || ''
* }
*/
};
}
/**
* Retorna a instância padrão (guarai).
*/
getDefaultInstance() {
return this.instances.guarai;
}
/**
* Retorna uma instância pelo nome da chave.
* Se a chave não existir, retorna a instância padrão.
*/
getInstanceByKey(key) {
return this.instances[key] || this.getDefaultInstance();
}
/**
* Seleciona a instância correta com base em um texto de localização livre.
* Útil quando o bot suportar múltiplas instâncias.
*/
getInstanceByLocation(location = '') {
const loc = location.toLowerCase();
if (loc.includes('pedro') || loc.includes('afonso')) {
return this.instances.pedro_afonso || this.getDefaultInstance();
}
return this.getDefaultInstance();
}
/**
* Verifica se ao menos uma instância está configurada (user + password).
*/
isReady() {
return Object.values(this.instances).some(
(inst) => inst.user && inst.password
);
}
/**
* Retorna a URL NBI completa de uma instância.
*/
getNBIUrl(instance) {
return `${instance.baseURL}:${instance.nbiPort}`;
}
}
module.exports = GenieACSConfig;
+5 -5
View File
@@ -1,5 +1,5 @@
const GenieACSApi = require('./api');
const GenieACSConfig = require('./config');
const GenieACSManager = require('./manager');
module.exports = { GenieACSApi, GenieACSConfig, GenieACSManager };
const GenieACSApi = require('./api');
const GenieACSConfig = require('./config');
const GenieACSManager = require('./manager');
module.exports = { GenieACSApi, GenieACSConfig, GenieACSManager };
+152 -152
View File
@@ -1,152 +1,152 @@
const GenieACSApi = require('./api');
const GenieACSConfig = require('./config');
const logger = require('../utils/logger');
class GenieACSManager {
constructor() {
this.api = new GenieACSApi();
this.config = new GenieACSConfig();
logger.info('GenieACSManager inicializado');
console.log('📡 GenieACSManager inicializado');
}
/**
* Verifica se ao menos uma instância está configurada.
*/
isReady() {
return this.config.isReady();
}
/**
* Busca informações de um roteador/CPE pelo serial number ou pela TAG (username TopSapp).
* Tenta por serial primeiro; se não encontrar, tenta por TAG.
* Se o termo contém "@", também tenta a parte antes do @ como TAG.
*
* @param {string} termo - Serial ou TAG do dispositivo
* @param {string} location - Texto de localização (opcional)
* @returns {Promise<Object>} - { success, device, instance, searchedBy, error }
*/
async buscarRoteador(termo, location = '') {
if (!this.isReady()) {
return {
success: false,
error: 'GenieACS não está configurado. Verifique as variáveis GENIEACS_* no arquivo .env'
};
}
const instance = this.config.getInstanceByLocation(location);
const termoLimpo = termo.trim();
// 1ª tentativa: busca por serial (exato e uppercase)
logger.info(`[GenieACS] Tentando como serial: "${termoLimpo}"`);
const porSerial = await this.api.buscarDispositivo(termoLimpo, instance);
if (porSerial.success) {
return { ...porSerial, instance: instance.name };
}
// 2ª tentativa: busca por TAG (termo completo)
logger.info(`[GenieACS] Não encontrado como serial. Tentando como TAG: "${termoLimpo}"`);
const porTag = await this.api.buscarPorTag(termoLimpo, instance);
if (porTag.success) {
return { ...porTag, instance: instance.name };
}
// 3ª tentativa: se o termo tem "@", tentar só a parte antes do @ como TAG
// (ex.: "clickcolmeia2025@isp" -> tenta "clickcolmeia2025")
if (termoLimpo.includes('@')) {
const tagBase = termoLimpo.split('@')[0].toLowerCase();
logger.info(`[GenieACS] Tentando TAG sem domínio: "${tagBase}"`);
const porTagBase = await this.api.buscarPorTag(tagBase, instance);
if (porTagBase.success) {
return { ...porTagBase, instance: instance.name };
}
}
// Nenhum resultado
const errConexao = porSerial.error || porTag.error;
return {
success: false,
error: errConexao || `Nenhum dispositivo encontrado com serial ou TAG "${termoLimpo}"`
};
}
/**
* Sincroniza o login PPPoE do TopSapp como TAG no GenieACS.
*
* Fluxo:
* 1. Busca o dispositivo pelo serial (vindo do TopSapp)
* 2. Deriva a TAG do login PPPoE (parte antes do "@", em minúsculo)
* 3. Se a TAG ainda não existir no dispositivo, adiciona via NBI
*
* @param {string} serial - Serial do dispositivo (do cadastro TopSapp)
* @param {string} pppoeLogin - Login PPPoE do cliente (ex.: "clickcolmeia2025" ou "user@isp")
* @param {string} location - Texto de localização (opcional)
* @returns {Promise<Object>} - { success, tag, deviceId, already, error }
*/
async sincronizarTagPPPoE(serial, pppoeLogin, location = '') {
if (!this.isReady() || !serial || !pppoeLogin) {
return { success: false, skipped: true };
}
const instance = this.config.getInstanceByLocation(location);
// A TAG usa apenas a parte antes do "@", em minúsculo,
// igualando ao padrão que o GenieACS já exibe na UI (ex.: "clickcolmeia2025")
const tag = pppoeLogin.split('@')[0].toLowerCase();
// Localizar dispositivo pelo serial
const result = await this.api.buscarDispositivo(serial.trim(), instance);
if (!result.success || !result.device?.deviceId) {
logger.warn(`[GenieACS] Sync TAG: dispositivo serial "${serial}" não encontrado`);
return { success: false, error: 'Dispositivo não encontrado pelo serial' };
}
const deviceId = result.device.deviceId;
const tagsAtuais = result.device.tags || [];
// Verificar se a tag já está presente (case-insensitive)
if (tagsAtuais.some(t => t.toLowerCase() === tag)) {
logger.info(`[GenieACS] Sync TAG: "${tag}" já existe em ${deviceId}`);
return { success: true, already: true, tag, deviceId };
}
// Adicionar a TAG
logger.info(`[GenieACS] Sync TAG: adicionando "${tag}" em ${deviceId}`);
const addResult = await this.api.adicionarTag(deviceId, tag, instance);
return { ...addResult, tag, deviceId };
}
/**
* Aplica um conjunto de setParameterValues a um dispositivo já conhecido pelo deviceId.
*
* @param {string} deviceId - _id do dispositivo no GenieACS
* @param {Array} paramValues - Array de [paramName, value, xsdType]
* @param {string} location - Texto de localização (opcional)
* @returns {Promise<Object>} - { success, error }
*/
async configurarRoteador(deviceId, paramValues, location = '') {
if (!this.isReady() || !deviceId) {
return { success: false, error: 'GenieACS não configurado ou deviceId ausente' };
}
const instance = this.config.getInstanceByLocation(location);
return this.api.setParameterValues(deviceId, paramValues, instance);
}
/**
* Relê um dispositivo pelo seu _id para obter dados atualizados.
*
* @param {string} deviceId - _id do dispositivo
* @param {string} location - Texto de localização (opcional)
* @returns {Promise<Object>} - { success, device, instance, searchedBy }
*/
async buscarRoteadorPorId(deviceId, location = '') {
if (!this.isReady() || !deviceId) {
return { success: false, error: 'GenieACS não configurado ou deviceId ausente' };
}
const instance = this.config.getInstanceByLocation(location);
const result = await this.api._buscar({ '_id': deviceId }, instance, 'id');
if (result.success) return { ...result, instance: instance.name };
return result;
}
}
module.exports = GenieACSManager;
const GenieACSApi = require('./api');
const GenieACSConfig = require('./config');
const logger = require('../utils/logger');
class GenieACSManager {
constructor() {
this.api = new GenieACSApi();
this.config = new GenieACSConfig();
logger.info('GenieACSManager inicializado');
console.log('📡 GenieACSManager inicializado');
}
/**
* Verifica se ao menos uma instância está configurada.
*/
isReady() {
return this.config.isReady();
}
/**
* Busca informações de um roteador/CPE pelo serial number ou pela TAG (username TopSapp).
* Tenta por serial primeiro; se não encontrar, tenta por TAG.
* Se o termo contém "@", também tenta a parte antes do @ como TAG.
*
* @param {string} termo - Serial ou TAG do dispositivo
* @param {string} location - Texto de localização (opcional)
* @returns {Promise<Object>} - { success, device, instance, searchedBy, error }
*/
async buscarRoteador(termo, location = '') {
if (!this.isReady()) {
return {
success: false,
error: 'GenieACS não está configurado. Verifique as variáveis GENIEACS_* no arquivo .env'
};
}
const instance = this.config.getInstanceByLocation(location);
const termoLimpo = termo.trim();
// 1ª tentativa: busca por serial (exato e uppercase)
logger.info(`[GenieACS] Tentando como serial: "${termoLimpo}"`);
const porSerial = await this.api.buscarDispositivo(termoLimpo, instance);
if (porSerial.success) {
return { ...porSerial, instance: instance.name };
}
// 2ª tentativa: busca por TAG (termo completo)
logger.info(`[GenieACS] Não encontrado como serial. Tentando como TAG: "${termoLimpo}"`);
const porTag = await this.api.buscarPorTag(termoLimpo, instance);
if (porTag.success) {
return { ...porTag, instance: instance.name };
}
// 3ª tentativa: se o termo tem "@", tentar só a parte antes do @ como TAG
// (ex.: "clickcolmeia2025@isp" -> tenta "clickcolmeia2025")
if (termoLimpo.includes('@')) {
const tagBase = termoLimpo.split('@')[0].toLowerCase();
logger.info(`[GenieACS] Tentando TAG sem domínio: "${tagBase}"`);
const porTagBase = await this.api.buscarPorTag(tagBase, instance);
if (porTagBase.success) {
return { ...porTagBase, instance: instance.name };
}
}
// Nenhum resultado
const errConexao = porSerial.error || porTag.error;
return {
success: false,
error: errConexao || `Nenhum dispositivo encontrado com serial ou TAG "${termoLimpo}"`
};
}
/**
* Sincroniza o login PPPoE do TopSapp como TAG no GenieACS.
*
* Fluxo:
* 1. Busca o dispositivo pelo serial (vindo do TopSapp)
* 2. Deriva a TAG do login PPPoE (parte antes do "@", em minúsculo)
* 3. Se a TAG ainda não existir no dispositivo, adiciona via NBI
*
* @param {string} serial - Serial do dispositivo (do cadastro TopSapp)
* @param {string} pppoeLogin - Login PPPoE do cliente (ex.: "clickcolmeia2025" ou "user@isp")
* @param {string} location - Texto de localização (opcional)
* @returns {Promise<Object>} - { success, tag, deviceId, already, error }
*/
async sincronizarTagPPPoE(serial, pppoeLogin, location = '') {
if (!this.isReady() || !serial || !pppoeLogin) {
return { success: false, skipped: true };
}
const instance = this.config.getInstanceByLocation(location);
// A TAG usa apenas a parte antes do "@", em minúsculo,
// igualando ao padrão que o GenieACS já exibe na UI (ex.: "clickcolmeia2025")
const tag = pppoeLogin.split('@')[0].toLowerCase();
// Localizar dispositivo pelo serial
const result = await this.api.buscarDispositivo(serial.trim(), instance);
if (!result.success || !result.device?.deviceId) {
logger.warn(`[GenieACS] Sync TAG: dispositivo serial "${serial}" não encontrado`);
return { success: false, error: 'Dispositivo não encontrado pelo serial' };
}
const deviceId = result.device.deviceId;
const tagsAtuais = result.device.tags || [];
// Verificar se a tag já está presente (case-insensitive)
if (tagsAtuais.some(t => t.toLowerCase() === tag)) {
logger.info(`[GenieACS] Sync TAG: "${tag}" já existe em ${deviceId}`);
return { success: true, already: true, tag, deviceId };
}
// Adicionar a TAG
logger.info(`[GenieACS] Sync TAG: adicionando "${tag}" em ${deviceId}`);
const addResult = await this.api.adicionarTag(deviceId, tag, instance);
return { ...addResult, tag, deviceId };
}
/**
* Aplica um conjunto de setParameterValues a um dispositivo já conhecido pelo deviceId.
*
* @param {string} deviceId - _id do dispositivo no GenieACS
* @param {Array} paramValues - Array de [paramName, value, xsdType]
* @param {string} location - Texto de localização (opcional)
* @returns {Promise<Object>} - { success, error }
*/
async configurarRoteador(deviceId, paramValues, location = '') {
if (!this.isReady() || !deviceId) {
return { success: false, error: 'GenieACS não configurado ou deviceId ausente' };
}
const instance = this.config.getInstanceByLocation(location);
return this.api.setParameterValues(deviceId, paramValues, instance);
}
/**
* Relê um dispositivo pelo seu _id para obter dados atualizados.
*
* @param {string} deviceId - _id do dispositivo
* @param {string} location - Texto de localização (opcional)
* @returns {Promise<Object>} - { success, device, instance, searchedBy }
*/
async buscarRoteadorPorId(deviceId, location = '') {
if (!this.isReady() || !deviceId) {
return { success: false, error: 'GenieACS não configurado ou deviceId ausente' };
}
const instance = this.config.getInstanceByLocation(location);
const result = await this.api._buscar({ '_id': deviceId }, instance, 'id');
if (result.success) return { ...result, instance: instance.name };
return result;
}
}
module.exports = GenieACSManager;
+46 -46
View File
@@ -1,46 +1,46 @@
require('dotenv').config();
const TelegramBot = require('node-telegram-bot-api');
const BotHandler = require('./bot/handler');
const config = require('./config/config');
// Criar instância do bot
const bot = new TelegramBot(config.telegram.token, { polling: true });
// Criar handler do bot
const botHandler = new BotHandler(bot);
// Log de inicialização
console.log('🤖 Bot de Ajuda iniciado!');
console.log(`📱 Token: ${config.telegram.token ? '✅ Configurado' : '❌ Não configurado'}`);
console.log(`🔐 Debug: ${config.bot.debug ? 'Ativado' : 'Desativado'}`);
// Event listeners
bot.on('message', (msg) => {
botHandler.handleMessage(msg);
});
bot.on('callback_query', (callbackQuery) => {
botHandler.handleCallbackQuery(callbackQuery);
});
// Error handling
bot.on('error', (error) => {
console.error('❌ Erro no bot:', error);
});
bot.on('polling_error', (error) => {
console.error('❌ Erro de polling:', error);
});
// Graceful shutdown
process.on('SIGINT', () => {
console.log('\n🛑 Encerrando bot...');
bot.stopPolling();
process.exit(0);
});
process.on('SIGTERM', () => {
console.log('\n🛑 Encerrando bot...');
bot.stopPolling();
process.exit(0);
});
require('dotenv').config();
const TelegramBot = require('node-telegram-bot-api');
const BotHandler = require('./bot/handler');
const config = require('./config/config');
// Criar instância do bot
const bot = new TelegramBot(config.telegram.token, { polling: true });
// Criar handler do bot
const botHandler = new BotHandler(bot);
// Log de inicialização
console.log('🤖 Bot de Ajuda iniciado!');
console.log(`📱 Token: ${config.telegram.token ? '✅ Configurado' : '❌ Não configurado'}`);
console.log(`🔐 Debug: ${config.bot.debug ? 'Ativado' : 'Desativado'}`);
// Event listeners
bot.on('message', (msg) => {
botHandler.handleMessage(msg);
});
bot.on('callback_query', (callbackQuery) => {
botHandler.handleCallbackQuery(callbackQuery);
});
// Error handling
bot.on('error', (error) => {
console.error('❌ Erro no bot:', error);
});
bot.on('polling_error', (error) => {
console.error('❌ Erro de polling:', error);
});
// Graceful shutdown
process.on('SIGINT', () => {
console.log('\n🛑 Encerrando bot...');
bot.stopPolling();
process.exit(0);
});
process.on('SIGTERM', () => {
console.log('\n🛑 Encerrando bot...');
bot.stopPolling();
process.exit(0);
});
+999 -999
View File
File diff suppressed because it is too large. Load diff
+1837 -1837
View File
File diff suppressed because it is too large. Load diff
+1045 -1045
View File
File diff suppressed because it is too large. Load diff
+90 -90
View File
@@ -1,90 +1,90 @@
/**
* Configurações do TopSapp
*/
class TopSappConfig {
constructor() {
this.credentials = {
guarai_colmeia: {
usuario: process.env.TOPSAPP_GUARAI_USER || '',
senha: process.env.TOPSAPP_GUARAI_PASS || '',
identificador: process.env.TOPSAPP_GUARAI_ID || 'BotTelegram',
idUsuario: process.env.TOPSAPP_GUARAI_USERID || '1'
},
pedro_afonso: {
usuario: process.env.TOPSAPP_PEDRO_AFONSO_USER || '',
senha: process.env.TOPSAPP_PEDRO_AFONSO_PASS || '',
identificador: process.env.TOPSAPP_PEDRO_AFONSO_ID || 'BotTelegram',
idUsuario: process.env.TOPSAPP_PEDRO_AFONSO_USERID || '1'
}
};
this.isConfigured = this.validateConfiguration();
}
/**
* Validar se as configurações estão completas
*/
validateConfiguration() {
const required = ['usuario', 'senha'];
for (const instance in this.credentials) {
const creds = this.credentials[instance];
for (const field of required) {
if (!creds[field]) {
console.warn(`⚠️ TopSapp: Campo ${field} não configurado para ${instance}`);
return false;
}
}
}
console.log('✅ TopSapp: Configurações validadas');
return true;
}
/**
* Obter credenciais para uma instância
*/
getCredentials(instanceKey) {
return this.credentials[instanceKey] || null;
}
/**
* Obter credenciais por localização
*/
getCredentialsByLocation(location = '') {
const loc = location.toLowerCase();
if (loc.includes('pedro') || loc.includes('afonso')) {
return this.credentials.pedro_afonso;
}
return this.credentials.guarai_colmeia;
}
/**
* Verificar se o TopSapp está configurado
*/
isReady() {
return this.isConfigured;
}
/**
* Obter status da configuração
*/
getStatus() {
const status = {};
for (const instance in this.credentials) {
const creds = this.credentials[instance];
status[instance] = {
configured: !!(creds.usuario && creds.senha),
usuario: creds.usuario ? '✅' : '❌',
senha: creds.senha ? '✅' : '❌'
};
}
return status;
}
}
module.exports = TopSappConfig;
/**
* Configurações do TopSapp
*/
class TopSappConfig {
constructor() {
this.credentials = {
guarai_colmeia: {
usuario: process.env.TOPSAPP_GUARAI_USER || '',
senha: process.env.TOPSAPP_GUARAI_PASS || '',
identificador: process.env.TOPSAPP_GUARAI_ID || 'BotTelegram',
idUsuario: process.env.TOPSAPP_GUARAI_USERID || '1'
},
pedro_afonso: {
usuario: process.env.TOPSAPP_PEDRO_AFONSO_USER || '',
senha: process.env.TOPSAPP_PEDRO_AFONSO_PASS || '',
identificador: process.env.TOPSAPP_PEDRO_AFONSO_ID || 'BotTelegram',
idUsuario: process.env.TOPSAPP_PEDRO_AFONSO_USERID || '1'
}
};
this.isConfigured = this.validateConfiguration();
}
/**
* Validar se as configurações estão completas
*/
validateConfiguration() {
const required = ['usuario', 'senha'];
for (const instance in this.credentials) {
const creds = this.credentials[instance];
for (const field of required) {
if (!creds[field]) {
console.warn(`⚠️ TopSapp: Campo ${field} não configurado para ${instance}`);
return false;
}
}
}
console.log('✅ TopSapp: Configurações validadas');
return true;
}
/**
* Obter credenciais para uma instância
*/
getCredentials(instanceKey) {
return this.credentials[instanceKey] || null;
}
/**
* Obter credenciais por localização
*/
getCredentialsByLocation(location = '') {
const loc = location.toLowerCase();
if (loc.includes('pedro') || loc.includes('afonso')) {
return this.credentials.pedro_afonso;
}
return this.credentials.guarai_colmeia;
}
/**
* Verificar se o TopSapp está configurado
*/
isReady() {
return this.isConfigured;
}
/**
* Obter status da configuração
*/
getStatus() {
const status = {};
for (const instance in this.credentials) {
const creds = this.credentials[instance];
status[instance] = {
configured: !!(creds.usuario && creds.senha),
usuario: creds.usuario ? '✅' : '❌',
senha: creds.senha ? '✅' : '❌'
};
}
return status;
}
}
module.exports = TopSappConfig;
+9 -9
View File
@@ -1,9 +1,9 @@
const TopSappAPI = require('./api');
const TopSappConfig = require('./config');
const TopSappManager = require('./manager');
module.exports = {
TopSappAPI,
TopSappConfig,
TopSappManager
};
const TopSappAPI = require('./api');
const TopSappConfig = require('./config');
const TopSappManager = require('./manager');
module.exports = {
TopSappAPI,
TopSappConfig,
TopSappManager
};
+1260 -1254
View File
File diff suppressed because it is too large. Load diff
+433 -433
View File
@@ -1,433 +1,433 @@
const logger = require('./logger');
class DateProcessor {
constructor() {
// Dicionário de dias da semana
this.diasSemana = {
'domingo': 0, 'dom': 0,
'segunda': 1, 'segunda-feira': 1, 'seg': 1,
'terça': 2, 'terca': 2, 'terça-feira': 2, 'terca-feira': 2, 'ter': 2,
'quarta': 3, 'quarta-feira': 3, 'qua': 3,
'quinta': 4, 'quinta-feira': 4, 'qui': 4,
'sexta': 5, 'sexta-feira': 5, 'sex': 5,
'sábado': 6, 'sabado': 6, 'sab': 6
};
// Dicionário de meses
this.meses = {
'janeiro': 0, 'jan': 0,
'fevereiro': 1, 'fev': 1,
'março': 2, 'mar': 2, 'marco': 2,
'abril': 3, 'abr': 3,
'maio': 4, 'mai': 4,
'junho': 5, 'jun': 5,
'julho': 6, 'jul': 6,
'agosto': 7, 'ago': 7,
'setembro': 8, 'set': 8,
'outubro': 9, 'out': 9,
'novembro': 10, 'nov': 10,
'dezembro': 11, 'dez': 11
};
// Palavras-chave temporais
this.palavrasTemporais = {
'hoje': 0,
'amanhã': 1, 'amanha': 1,
'depois de amanhã': 2, 'depois de amanha': 2,
'ontem': -1,
'anteontem': -2
};
}
/**
* Processar texto em linguagem natural para extrair data e hora
* @param {string} texto - Texto em linguagem natural
* @returns {Object} - { success: boolean, data: Date, originalText: string, parsed: Object }
*/
processarTexto(texto) {
if (!texto || typeof texto !== 'string') {
return {
success: false,
error: 'Texto inválido'
};
}
const textoNormalizado = texto.toLowerCase().trim();
logger.debug('Processando texto de data/hora:', { texto: textoNormalizado });
try {
// Tentar diferentes padrões de reconhecimento
let resultado = null;
// 1. Padrões com dias da semana
resultado = this.processarDiaSemana(textoNormalizado);
if (resultado.success) return resultado;
// 2. Padrões com palavras temporais (hoje, amanhã, etc)
resultado = this.processarPalavrasTemporal(textoNormalizado);
if (resultado.success) return resultado;
// 3. Padrões de data específica (DD/MM, DD/MM/YYYY)
resultado = this.processarDataEspecifica(textoNormalizado);
if (resultado.success) return resultado;
// 4. Padrões relativos (em X dias, daqui a X horas)
resultado = this.processarDataRelativa(textoNormalizado);
if (resultado.success) return resultado;
// Se não conseguiu processar
return {
success: false,
error: 'Não foi possível interpretar a data/hora',
originalText: texto
};
} catch (error) {
logger.error('Erro ao processar data/hora:', error);
return {
success: false,
error: error.message,
originalText: texto
};
}
}
/**
* Processar padrões com dias da semana
*/
processarDiaSemana(texto) {
// Padrões: "sexta às 14h", "na segunda 09:00", "sexta-feira 14:30"
const padroes = [
/(?:na\s+)?(\w+(?:-\w+)?)\s+(?:às?\s*)?(\d{1,2}):?(\d{0,2})(?:h)?/i,
/(?:na\s+)?(\w+(?:-\w+)?)\s+(?:às?\s*)?(\d{1,2})(?:h)?(\d{0,2})?/i
];
for (const padrao of padroes) {
const match = texto.match(padrao);
if (match) {
const diaNome = match[1].toLowerCase();
const hora = parseInt(match[2]);
const minuto = match[3] ? parseInt(match[3]) : 0;
if (this.diasSemana.hasOwnProperty(diaNome)) {
const diaSemanaTarget = this.diasSemana[diaNome];
const proxData = this.obterProximoDiaSemana(diaSemanaTarget);
if (this.isValidTime(hora, minuto)) {
// Obter componentes da data no fuso Brasil
const partes = this.extrairPartesBrasil(proxData);
const data = this.criarDataBrasil(partes.ano, partes.mes, partes.dia, hora, minuto);
return {
success: true,
data,
originalText: texto,
parsed: {
type: 'dia_semana',
dia: diaNome,
hora,
minuto
}
};
}
}
}
}
return { success: false };
}
/**
* Processar palavras temporais (hoje, amanhã, etc)
*/
processarPalavrasTemporal(texto) {
// Padrões: "amanhã às 17h", "hoje 14:30", "depois de amanhã 09h"
const padroes = [
/(hoje|amanhã|amanha|depois de amanhã|depois de amanha)\s+(?:às?\s*)?(\d{1,2}):?(\d{0,2})(?:h)?/i,
/(hoje|amanhã|amanha|depois de amanhã|depois de amanha)\s+(?:às?\s*)?(\d{1,2})(?:h)?(\d{0,2})?/i
];
for (const padrao of padroes) {
const match = texto.match(padrao);
if (match) {
const palavraTemporal = match[1].toLowerCase();
const hora = parseInt(match[2]);
const minuto = match[3] ? parseInt(match[3]) : 0;
if (this.palavrasTemporais.hasOwnProperty(palavraTemporal)) {
const diasOffset = this.palavrasTemporais[palavraTemporal];
if (this.isValidTime(hora, minuto)) {
// Basear no dia atual no fuso Brasil e aplicar offset
const hoje = this.extrairPartesBrasil(new Date());
const base = this.criarDataBrasil(hoje.ano, hoje.mes, hoje.dia, 12, 0);
base.setDate(base.getDate() + diasOffset);
const partes = this.extrairPartesBrasil(base);
const data = this.criarDataBrasil(partes.ano, partes.mes, partes.dia, hora, minuto);
return {
success: true,
data,
originalText: texto,
parsed: {
type: 'palavra_temporal',
palavra: palavraTemporal,
offset: diasOffset,
hora,
minuto
}
};
}
}
}
}
return { success: false };
}
/**
* Processar datas específicas
*/
processarDataEspecifica(texto) {
// Padrões: "15/08 às 14h", "15/08/2024 14:30", "15 de agosto 09h"
const padroes = [
/(\d{1,2})\/(\d{1,2})(?:\/(\d{2,4}))?\s+(?:às?\s*)?(\d{1,2}):?(\d{0,2})(?:h)?/i,
/(\d{1,2})\s+de\s+(\w+)(?:\s+de\s+(\d{2,4}))?\s+(?:às?\s*)?(\d{1,2}):?(\d{0,2})(?:h)?/i
];
for (const padrao of padroes) {
const match = texto.match(padrao);
if (match) {
let dia, mes, ano, hora, minuto;
if (match[0].includes('/')) {
// Formato DD/MM/YYYY
dia = parseInt(match[1]);
mes = parseInt(match[2]) - 1; // JavaScript usa 0-11 para meses
ano = match[3] ? parseInt(match[3]) : new Date().getFullYear();
hora = parseInt(match[4]);
minuto = match[5] ? parseInt(match[5]) : 0;
} else {
// Formato "DD de MMMM"
dia = parseInt(match[1]);
const mesNome = match[2].toLowerCase();
mes = this.meses[mesNome];
ano = match[3] ? parseInt(match[3]) : new Date().getFullYear();
hora = parseInt(match[4]);
minuto = match[5] ? parseInt(match[5]) : 0;
}
if (ano < 100) ano += 2000; // Converter anos de 2 dígitos
if (mes !== undefined && this.isValidDate(dia, mes, ano) && this.isValidTime(hora, minuto)) {
const data = this.criarDataBrasil(ano, mes, dia, hora, minuto);
return {
success: true,
data,
originalText: texto,
parsed: {
type: 'data_especifica',
dia,
mes: mes + 1, // Para display humano
ano,
hora,
minuto
}
};
}
}
}
return { success: false };
}
/**
* Processar datas relativas
*/
processarDataRelativa(texto) {
// Padrões: "em 3 dias às 14h", "daqui a 2 horas", "em 1 semana"
const padroes = [
/(?:em|daqui\s+a)\s+(\d+)\s+(dias?|semanas?|meses?)\s+(?:às?\s*)?(\d{1,2}):?(\d{0,2})(?:h)?/i,
/(?:em|daqui\s+a)\s+(\d+)\s+(horas?|minutos?)/i
];
for (const padrao of padroes) {
const match = texto.match(padrao);
if (match) {
const quantidade = parseInt(match[1]);
const unidade = match[2].toLowerCase();
let data = new Date();
if (unidade.includes('dia')) {
const base = this.extrairPartesBrasil(new Date());
const tmpDate = this.criarDataBrasil(base.ano, base.mes, base.dia, 12, 0);
tmpDate.setDate(tmpDate.getDate() + quantidade);
if (match[3] && match[4] !== undefined) {
const hora = parseInt(match[3]);
const minuto = match[4] ? parseInt(match[4]) : 0;
if (this.isValidTime(hora, minuto)) {
const partes = this.extrairPartesBrasil(tmpDate);
data = this.criarDataBrasil(partes.ano, partes.mes, partes.dia, hora, minuto);
} else {
data = tmpDate;
}
} else {
data = tmpDate;
}
} else if (unidade.includes('semana')) {
const base = this.extrairPartesBrasil(new Date());
const tmpDate = this.criarDataBrasil(base.ano, base.mes, base.dia, 12, 0);
tmpDate.setDate(tmpDate.getDate() + (quantidade * 7));
if (match[3] && match[4] !== undefined) {
const hora = parseInt(match[3]);
const minuto = match[4] ? parseInt(match[4]) : 0;
if (this.isValidTime(hora, minuto)) {
const partes = this.extrairPartesBrasil(tmpDate);
data = this.criarDataBrasil(partes.ano, partes.mes, partes.dia, hora, minuto);
} else {
data = tmpDate;
}
} else {
data = tmpDate;
}
} else if (unidade.includes('mes')) {
const base = this.extrairPartesBrasil(new Date());
const tmpDate = this.criarDataBrasil(base.ano, base.mes, base.dia, 12, 0);
tmpDate.setMonth(tmpDate.getMonth() + quantidade);
if (match[3] && match[4] !== undefined) {
const hora = parseInt(match[3]);
const minuto = match[4] ? parseInt(match[4]) : 0;
if (this.isValidTime(hora, minuto)) {
const partes = this.extrairPartesBrasil(tmpDate);
data = this.criarDataBrasil(partes.ano, partes.mes, partes.dia, hora, minuto);
} else {
data = tmpDate;
}
} else {
data = tmpDate;
}
} else if (unidade.includes('hora')) {
data.setHours(data.getHours() + quantidade);
} else if (unidade.includes('minuto')) {
data.setMinutes(data.getMinutes() + quantidade);
}
return {
success: true,
data,
originalText: texto,
parsed: {
type: 'data_relativa',
quantidade,
unidade
}
};
}
}
return { success: false };
}
/**
* Obter próximo dia da semana
*/
obterProximoDiaSemana(diaSemanaTarget) {
const hoje = new Date();
const diaAtual = hoje.getDay();
let diasParaAdicionar = diaSemanaTarget - diaAtual;
if (diasParaAdicionar <= 0) {
diasParaAdicionar += 7; // Próxima semana
}
const proximaData = new Date(hoje);
proximaData.setDate(hoje.getDate() + diasParaAdicionar);
return proximaData;
}
/**
* Validar se a data é válida
*/
isValidDate(dia, mes, ano) {
if (dia < 1 || dia > 31) return false;
if (mes < 0 || mes > 11) return false;
if (ano < 1900 || ano > 2100) return false;
// Verificar dias válidos para o mês
const diasNoMes = new Date(ano, mes + 1, 0).getDate();
return dia <= diasNoMes;
}
/**
* Validar se a hora é válida
*/
isValidTime(hora, minuto) {
return hora >= 0 && hora <= 23 && minuto >= 0 && minuto <= 59;
}
/**
* Criar um Date com hora no fuso America/Araguaina,
* independente do TZ do servidor.
*/
criarDataBrasil(ano, mes, dia, hora, minuto) {
// Constrói uma string ISO no fuso -03:00 para garantir
// que a hora digitada pelo usuário seja tratada como BR.
const pad = (n) => String(n).padStart(2, '0');
const isoStr = `${ano}-${pad(mes + 1)}-${pad(dia)}T${pad(hora)}:${pad(minuto)}:00-03:00`;
return new Date(isoStr);
}
/**
* Extrair {ano, mes(0-11), dia} de um Date no fuso Brasil.
*/
extrairPartesBrasil(data) {
const fmt = new Intl.DateTimeFormat('en-CA', {
timeZone: 'America/Araguaina',
year: 'numeric', month: '2-digit', day: '2-digit'
});
// en-CA produz YYYY-MM-DD
const [ano, mes, dia] = fmt.format(data).split('-').map(Number);
return { ano, mes: mes - 1, dia };
}
/**
* Formatar data para string legível
*/
formatarData(data) {
if (!(data instanceof Date)) return 'Data inválida';
const opcoes = {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
timeZone: 'America/Araguaina'
};
return data.toLocaleDateString('pt-BR', opcoes);
}
/**
* Formatar data para o formato do sistema (YYYY-MM-DD HH:mm:ss)
* Retorna sempre no horário de Brasília.
*/
formatarParaSistema(data) {
if (!(data instanceof Date)) return null;
// Formata no fuso de Brasília para não depender do TZ do servidor
const fmt = new Intl.DateTimeFormat('sv-SE', {
timeZone: 'America/Araguaina',
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit',
hour12: false
});
// sv-SE produz YYYY-MM-DD HH:mm:ss
return fmt.format(data).replace('T', ' ');
}
}
module.exports = DateProcessor;
const logger = require('./logger');
class DateProcessor {
constructor() {
// Dicionário de dias da semana
this.diasSemana = {
'domingo': 0, 'dom': 0,
'segunda': 1, 'segunda-feira': 1, 'seg': 1,
'terça': 2, 'terca': 2, 'terça-feira': 2, 'terca-feira': 2, 'ter': 2,
'quarta': 3, 'quarta-feira': 3, 'qua': 3,
'quinta': 4, 'quinta-feira': 4, 'qui': 4,
'sexta': 5, 'sexta-feira': 5, 'sex': 5,
'sábado': 6, 'sabado': 6, 'sab': 6
};
// Dicionário de meses
this.meses = {
'janeiro': 0, 'jan': 0,
'fevereiro': 1, 'fev': 1,
'março': 2, 'mar': 2, 'marco': 2,
'abril': 3, 'abr': 3,
'maio': 4, 'mai': 4,
'junho': 5, 'jun': 5,
'julho': 6, 'jul': 6,
'agosto': 7, 'ago': 7,
'setembro': 8, 'set': 8,
'outubro': 9, 'out': 9,
'novembro': 10, 'nov': 10,
'dezembro': 11, 'dez': 11
};
// Palavras-chave temporais
this.palavrasTemporais = {
'hoje': 0,
'amanhã': 1, 'amanha': 1,
'depois de amanhã': 2, 'depois de amanha': 2,
'ontem': -1,
'anteontem': -2
};
}
/**
* Processar texto em linguagem natural para extrair data e hora
* @param {string} texto - Texto em linguagem natural
* @returns {Object} - { success: boolean, data: Date, originalText: string, parsed: Object }
*/
processarTexto(texto) {
if (!texto || typeof texto !== 'string') {
return {
success: false,
error: 'Texto inválido'
};
}
const textoNormalizado = texto.toLowerCase().trim();
logger.debug('Processando texto de data/hora:', { texto: textoNormalizado });
try {
// Tentar diferentes padrões de reconhecimento
let resultado = null;
// 1. Padrões com dias da semana
resultado = this.processarDiaSemana(textoNormalizado);
if (resultado.success) return resultado;
// 2. Padrões com palavras temporais (hoje, amanhã, etc)
resultado = this.processarPalavrasTemporal(textoNormalizado);
if (resultado.success) return resultado;
// 3. Padrões de data específica (DD/MM, DD/MM/YYYY)
resultado = this.processarDataEspecifica(textoNormalizado);
if (resultado.success) return resultado;
// 4. Padrões relativos (em X dias, daqui a X horas)
resultado = this.processarDataRelativa(textoNormalizado);
if (resultado.success) return resultado;
// Se não conseguiu processar
return {
success: false,
error: 'Não foi possível interpretar a data/hora',
originalText: texto
};
} catch (error) {
logger.error('Erro ao processar data/hora:', error);
return {
success: false,
error: error.message,
originalText: texto
};
}
}
/**
* Processar padrões com dias da semana
*/
processarDiaSemana(texto) {
// Padrões: "sexta às 14h", "na segunda 09:00", "sexta-feira 14:30"
const padroes = [
/(?:na\s+)?(\w+(?:-\w+)?)\s+(?:às?\s*)?(\d{1,2}):?(\d{0,2})(?:h)?/i,
/(?:na\s+)?(\w+(?:-\w+)?)\s+(?:às?\s*)?(\d{1,2})(?:h)?(\d{0,2})?/i
];
for (const padrao of padroes) {
const match = texto.match(padrao);
if (match) {
const diaNome = match[1].toLowerCase();
const hora = parseInt(match[2]);
const minuto = match[3] ? parseInt(match[3]) : 0;
if (this.diasSemana.hasOwnProperty(diaNome)) {
const diaSemanaTarget = this.diasSemana[diaNome];
const proxData = this.obterProximoDiaSemana(diaSemanaTarget);
if (this.isValidTime(hora, minuto)) {
// Obter componentes da data no fuso Brasil
const partes = this.extrairPartesBrasil(proxData);
const data = this.criarDataBrasil(partes.ano, partes.mes, partes.dia, hora, minuto);
return {
success: true,
data,
originalText: texto,
parsed: {
type: 'dia_semana',
dia: diaNome,
hora,
minuto
}
};
}
}
}
}
return { success: false };
}
/**
* Processar palavras temporais (hoje, amanhã, etc)
*/
processarPalavrasTemporal(texto) {
// Padrões: "amanhã às 17h", "hoje 14:30", "depois de amanhã 09h"
const padroes = [
/(hoje|amanhã|amanha|depois de amanhã|depois de amanha)\s+(?:às?\s*)?(\d{1,2}):?(\d{0,2})(?:h)?/i,
/(hoje|amanhã|amanha|depois de amanhã|depois de amanha)\s+(?:às?\s*)?(\d{1,2})(?:h)?(\d{0,2})?/i
];
for (const padrao of padroes) {
const match = texto.match(padrao);
if (match) {
const palavraTemporal = match[1].toLowerCase();
const hora = parseInt(match[2]);
const minuto = match[3] ? parseInt(match[3]) : 0;
if (this.palavrasTemporais.hasOwnProperty(palavraTemporal)) {
const diasOffset = this.palavrasTemporais[palavraTemporal];
if (this.isValidTime(hora, minuto)) {
// Basear no dia atual no fuso Brasil e aplicar offset
const hoje = this.extrairPartesBrasil(new Date());
const base = this.criarDataBrasil(hoje.ano, hoje.mes, hoje.dia, 12, 0);
base.setDate(base.getDate() + diasOffset);
const partes = this.extrairPartesBrasil(base);
const data = this.criarDataBrasil(partes.ano, partes.mes, partes.dia, hora, minuto);
return {
success: true,
data,
originalText: texto,
parsed: {
type: 'palavra_temporal',
palavra: palavraTemporal,
offset: diasOffset,
hora,
minuto
}
};
}
}
}
}
return { success: false };
}
/**
* Processar datas específicas
*/
processarDataEspecifica(texto) {
// Padrões: "15/08 às 14h", "15/08/2024 14:30", "15 de agosto 09h"
const padroes = [
/(\d{1,2})\/(\d{1,2})(?:\/(\d{2,4}))?\s+(?:às?\s*)?(\d{1,2}):?(\d{0,2})(?:h)?/i,
/(\d{1,2})\s+de\s+(\w+)(?:\s+de\s+(\d{2,4}))?\s+(?:às?\s*)?(\d{1,2}):?(\d{0,2})(?:h)?/i
];
for (const padrao of padroes) {
const match = texto.match(padrao);
if (match) {
let dia, mes, ano, hora, minuto;
if (match[0].includes('/')) {
// Formato DD/MM/YYYY
dia = parseInt(match[1]);
mes = parseInt(match[2]) - 1; // JavaScript usa 0-11 para meses
ano = match[3] ? parseInt(match[3]) : new Date().getFullYear();
hora = parseInt(match[4]);
minuto = match[5] ? parseInt(match[5]) : 0;
} else {
// Formato "DD de MMMM"
dia = parseInt(match[1]);
const mesNome = match[2].toLowerCase();
mes = this.meses[mesNome];
ano = match[3] ? parseInt(match[3]) : new Date().getFullYear();
hora = parseInt(match[4]);
minuto = match[5] ? parseInt(match[5]) : 0;
}
if (ano < 100) ano += 2000; // Converter anos de 2 dígitos
if (mes !== undefined && this.isValidDate(dia, mes, ano) && this.isValidTime(hora, minuto)) {
const data = this.criarDataBrasil(ano, mes, dia, hora, minuto);
return {
success: true,
data,
originalText: texto,
parsed: {
type: 'data_especifica',
dia,
mes: mes + 1, // Para display humano
ano,
hora,
minuto
}
};
}
}
}
return { success: false };
}
/**
* Processar datas relativas
*/
processarDataRelativa(texto) {
// Padrões: "em 3 dias às 14h", "daqui a 2 horas", "em 1 semana"
const padroes = [
/(?:em|daqui\s+a)\s+(\d+)\s+(dias?|semanas?|meses?)\s+(?:às?\s*)?(\d{1,2}):?(\d{0,2})(?:h)?/i,
/(?:em|daqui\s+a)\s+(\d+)\s+(horas?|minutos?)/i
];
for (const padrao of padroes) {
const match = texto.match(padrao);
if (match) {
const quantidade = parseInt(match[1]);
const unidade = match[2].toLowerCase();
let data = new Date();
if (unidade.includes('dia')) {
const base = this.extrairPartesBrasil(new Date());
const tmpDate = this.criarDataBrasil(base.ano, base.mes, base.dia, 12, 0);
tmpDate.setDate(tmpDate.getDate() + quantidade);
if (match[3] && match[4] !== undefined) {
const hora = parseInt(match[3]);
const minuto = match[4] ? parseInt(match[4]) : 0;
if (this.isValidTime(hora, minuto)) {
const partes = this.extrairPartesBrasil(tmpDate);
data = this.criarDataBrasil(partes.ano, partes.mes, partes.dia, hora, minuto);
} else {
data = tmpDate;
}
} else {
data = tmpDate;
}
} else if (unidade.includes('semana')) {
const base = this.extrairPartesBrasil(new Date());
const tmpDate = this.criarDataBrasil(base.ano, base.mes, base.dia, 12, 0);
tmpDate.setDate(tmpDate.getDate() + (quantidade * 7));
if (match[3] && match[4] !== undefined) {
const hora = parseInt(match[3]);
const minuto = match[4] ? parseInt(match[4]) : 0;
if (this.isValidTime(hora, minuto)) {
const partes = this.extrairPartesBrasil(tmpDate);
data = this.criarDataBrasil(partes.ano, partes.mes, partes.dia, hora, minuto);
} else {
data = tmpDate;
}
} else {
data = tmpDate;
}
} else if (unidade.includes('mes')) {
const base = this.extrairPartesBrasil(new Date());
const tmpDate = this.criarDataBrasil(base.ano, base.mes, base.dia, 12, 0);
tmpDate.setMonth(tmpDate.getMonth() + quantidade);
if (match[3] && match[4] !== undefined) {
const hora = parseInt(match[3]);
const minuto = match[4] ? parseInt(match[4]) : 0;
if (this.isValidTime(hora, minuto)) {
const partes = this.extrairPartesBrasil(tmpDate);
data = this.criarDataBrasil(partes.ano, partes.mes, partes.dia, hora, minuto);
} else {
data = tmpDate;
}
} else {
data = tmpDate;
}
} else if (unidade.includes('hora')) {
data.setHours(data.getHours() + quantidade);
} else if (unidade.includes('minuto')) {
data.setMinutes(data.getMinutes() + quantidade);
}
return {
success: true,
data,
originalText: texto,
parsed: {
type: 'data_relativa',
quantidade,
unidade
}
};
}
}
return { success: false };
}
/**
* Obter próximo dia da semana
*/
obterProximoDiaSemana(diaSemanaTarget) {
const hoje = new Date();
const diaAtual = hoje.getDay();
let diasParaAdicionar = diaSemanaTarget - diaAtual;
if (diasParaAdicionar <= 0) {
diasParaAdicionar += 7; // Próxima semana
}
const proximaData = new Date(hoje);
proximaData.setDate(hoje.getDate() + diasParaAdicionar);
return proximaData;
}
/**
* Validar se a data é válida
*/
isValidDate(dia, mes, ano) {
if (dia < 1 || dia > 31) return false;
if (mes < 0 || mes > 11) return false;
if (ano < 1900 || ano > 2100) return false;
// Verificar dias válidos para o mês
const diasNoMes = new Date(ano, mes + 1, 0).getDate();
return dia <= diasNoMes;
}
/**
* Validar se a hora é válida
*/
isValidTime(hora, minuto) {
return hora >= 0 && hora <= 23 && minuto >= 0 && minuto <= 59;
}
/**
* Criar um Date com hora no fuso America/Araguaina,
* independente do TZ do servidor.
*/
criarDataBrasil(ano, mes, dia, hora, minuto) {
// Constrói uma string ISO no fuso -03:00 para garantir
// que a hora digitada pelo usuário seja tratada como BR.
const pad = (n) => String(n).padStart(2, '0');
const isoStr = `${ano}-${pad(mes + 1)}-${pad(dia)}T${pad(hora)}:${pad(minuto)}:00-03:00`;
return new Date(isoStr);
}
/**
* Extrair {ano, mes(0-11), dia} de um Date no fuso Brasil.
*/
extrairPartesBrasil(data) {
const fmt = new Intl.DateTimeFormat('en-CA', {
timeZone: 'America/Araguaina',
year: 'numeric', month: '2-digit', day: '2-digit'
});
// en-CA produz YYYY-MM-DD
const [ano, mes, dia] = fmt.format(data).split('-').map(Number);
return { ano, mes: mes - 1, dia };
}
/**
* Formatar data para string legível
*/
formatarData(data) {
if (!(data instanceof Date)) return 'Data inválida';
const opcoes = {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
timeZone: 'America/Araguaina'
};
return data.toLocaleDateString('pt-BR', opcoes);
}
/**
* Formatar data para o formato do sistema (YYYY-MM-DD HH:mm:ss)
* Retorna sempre no horário de Brasília.
*/
formatarParaSistema(data) {
if (!(data instanceof Date)) return null;
// Formata no fuso de Brasília para não depender do TZ do servidor
const fmt = new Intl.DateTimeFormat('sv-SE', {
timeZone: 'America/Araguaina',
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit',
hour12: false
});
// sv-SE produz YYYY-MM-DD HH:mm:ss
return fmt.format(data).replace('T', ' ');
}
}
module.exports = DateProcessor;
+173 -173
View File
@@ -1,173 +1,173 @@
const fs = require('fs');
const path = require('path');
class Logger {
constructor() {
this.logDir = path.join(__dirname, '../../logs');
this.logFile = path.join(this.logDir, 'bot-debug.log');
this.maxLogSize = 10 * 1024 * 1024; // 10MB
this.maxBackups = 5;
// Criar diretório de logs se não existir
this.ensureLogDir();
// Só ativa logs se estiver em modo debug
this.isDebug = process.env.NODE_ENV === 'development' || process.env.DEBUG === 'true';
}
ensureLogDir() {
if (!fs.existsSync(this.logDir)) {
fs.mkdirSync(this.logDir, { recursive: true });
}
}
formatMessage(level, message, data = null) {
const timestamp = new Date().toISOString();
let logLine = `[${timestamp}] [${level}] ${message}`;
if (data) {
if (typeof data === 'object') {
logLine += '\n' + JSON.stringify(data, null, 2);
} else {
logLine += ` - ${data}`;
}
}
return logLine + '\n';
}
rotateLogIfNeeded() {
if (!fs.existsSync(this.logFile)) {
return;
}
const stats = fs.statSync(this.logFile);
if (stats.size > this.maxLogSize) {
// Fazer backup dos logs existentes
for (let i = this.maxBackups - 1; i >= 0; i--) {
const oldFile = i === 0 ? this.logFile : `${this.logFile}.${i}`;
const newFile = `${this.logFile}.${i + 1}`;
if (fs.existsSync(oldFile)) {
if (i === this.maxBackups - 1) {
fs.unlinkSync(oldFile); // Deletar o mais antigo
} else {
fs.renameSync(oldFile, newFile);
}
}
}
}
}
writeLog(level, message, data = null) {
if (!this.isDebug) {
return; // Só loga em modo debug
}
try {
this.rotateLogIfNeeded();
const logMessage = this.formatMessage(level, message, data);
// Escrever no arquivo de log
fs.appendFileSync(this.logFile, logMessage);
// Também mostrar no console se for erro ou warning
if (level === 'ERROR' || level === 'WARN') {
console.log(logMessage.trim());
}
} catch (error) {
console.error('Erro ao escrever log:', error);
}
}
debug(message, data = null) {
this.writeLog('DEBUG', message, data);
}
info(message, data = null) {
this.writeLog('INFO', message, data);
}
warn(message, data = null) {
this.writeLog('WARN', message, data);
}
error(message, data = null) {
this.writeLog('ERROR', message, data);
}
// Métodos específicos para o bot
botCommand(userId, command, data = null) {
this.debug(`Comando recebido - User: ${userId}, Command: ${command}`, data);
}
apiCall(endpoint, params = null, response = null) {
this.debug(`API Call - Endpoint: ${endpoint}`, {
params,
responseStatus: response?.status,
responseDataLength: response?.data?.length
});
}
botError(userId, error, context = null) {
this.error(`Bot Error - User: ${userId}`, {
error: error.message,
stack: error.stack,
context
});
}
topsappOperation(operation, clientId, result = null) {
this.debug(`TopSapp Operation - ${operation} - Cliente: ${clientId}`, {
success: result?.success,
dataCount: result?.data?.length || result?.servicos?.length || result?.clientes?.length,
error: result?.error
});
}
// Limpar logs antigos (método utilitário)
clearLogs() {
if (!this.isDebug) {
return;
}
try {
if (fs.existsSync(this.logFile)) {
fs.unlinkSync(this.logFile);
}
// Limpar backups também
for (let i = 1; i <= this.maxBackups; i++) {
const backupFile = `${this.logFile}.${i}`;
if (fs.existsSync(backupFile)) {
fs.unlinkSync(backupFile);
}
}
this.info('Logs limpos com sucesso');
} catch (error) {
console.error('Erro ao limpar logs:', error);
}
}
// Obter últimas linhas do log
getRecentLogs(lines = 100) {
if (!this.isDebug || !fs.existsSync(this.logFile)) {
return [];
}
try {
const content = fs.readFileSync(this.logFile, 'utf8');
const allLines = content.split('\n').filter(line => line.trim());
return allLines.slice(-lines);
} catch (error) {
console.error('Erro ao ler logs:', error);
return [];
}
}
}
// Singleton instance
const logger = new Logger();
module.exports = logger;
const fs = require('fs');
const path = require('path');
class Logger {
constructor() {
this.logDir = path.join(__dirname, '../../logs');
this.logFile = path.join(this.logDir, 'bot-debug.log');
this.maxLogSize = 10 * 1024 * 1024; // 10MB
this.maxBackups = 5;
// Criar diretório de logs se não existir
this.ensureLogDir();
// Só ativa logs se estiver em modo debug
this.isDebug = process.env.NODE_ENV === 'development' || process.env.DEBUG === 'true';
}
ensureLogDir() {
if (!fs.existsSync(this.logDir)) {
fs.mkdirSync(this.logDir, { recursive: true });
}
}
formatMessage(level, message, data = null) {
const timestamp = new Date().toISOString();
let logLine = `[${timestamp}] [${level}] ${message}`;
if (data) {
if (typeof data === 'object') {
logLine += '\n' + JSON.stringify(data, null, 2);
} else {
logLine += ` - ${data}`;
}
}
return logLine + '\n';
}
rotateLogIfNeeded() {
if (!fs.existsSync(this.logFile)) {
return;
}
const stats = fs.statSync(this.logFile);
if (stats.size > this.maxLogSize) {
// Fazer backup dos logs existentes
for (let i = this.maxBackups - 1; i >= 0; i--) {
const oldFile = i === 0 ? this.logFile : `${this.logFile}.${i}`;
const newFile = `${this.logFile}.${i + 1}`;
if (fs.existsSync(oldFile)) {
if (i === this.maxBackups - 1) {
fs.unlinkSync(oldFile); // Deletar o mais antigo
} else {
fs.renameSync(oldFile, newFile);
}
}
}
}
}
writeLog(level, message, data = null) {
if (!this.isDebug) {
return; // Só loga em modo debug
}
try {
this.rotateLogIfNeeded();
const logMessage = this.formatMessage(level, message, data);
// Escrever no arquivo de log
fs.appendFileSync(this.logFile, logMessage);
// Também mostrar no console se for erro ou warning
if (level === 'ERROR' || level === 'WARN') {
console.log(logMessage.trim());
}
} catch (error) {
console.error('Erro ao escrever log:', error);
}
}
debug(message, data = null) {
this.writeLog('DEBUG', message, data);
}
info(message, data = null) {
this.writeLog('INFO', message, data);
}
warn(message, data = null) {
this.writeLog('WARN', message, data);
}
error(message, data = null) {
this.writeLog('ERROR', message, data);
}
// Métodos específicos para o bot
botCommand(userId, command, data = null) {
this.debug(`Comando recebido - User: ${userId}, Command: ${command}`, data);
}
apiCall(endpoint, params = null, response = null) {
this.debug(`API Call - Endpoint: ${endpoint}`, {
params,
responseStatus: response?.status,
responseDataLength: response?.data?.length
});
}
botError(userId, error, context = null) {
this.error(`Bot Error - User: ${userId}`, {
error: error.message,
stack: error.stack,
context
});
}
topsappOperation(operation, clientId, result = null) {
this.debug(`TopSapp Operation - ${operation} - Cliente: ${clientId}`, {
success: result?.success,
dataCount: result?.data?.length || result?.servicos?.length || result?.clientes?.length,
error: result?.error
});
}
// Limpar logs antigos (método utilitário)
clearLogs() {
if (!this.isDebug) {
return;
}
try {
if (fs.existsSync(this.logFile)) {
fs.unlinkSync(this.logFile);
}
// Limpar backups também
for (let i = 1; i <= this.maxBackups; i++) {
const backupFile = `${this.logFile}.${i}`;
if (fs.existsSync(backupFile)) {
fs.unlinkSync(backupFile);
}
}
this.info('Logs limpos com sucesso');
} catch (error) {
console.error('Erro ao limpar logs:', error);
}
}
// Obter últimas linhas do log
getRecentLogs(lines = 100) {
if (!this.isDebug || !fs.existsSync(this.logFile)) {
return [];
}
try {
const content = fs.readFileSync(this.logFile, 'utf8');
const allLines = content.split('\n').filter(line => line.trim());
return allLines.slice(-lines);
} catch (error) {
console.error('Erro ao ler logs:', error);
return [];
}
}
}
// Singleton instance
const logger = new Logger();
module.exports = logger;