Aggiunta stili CSS per Kiosk, struttura HTML per la Mappa e Riferimenti ai Sensori

• Creato un nuovo file CSS per gli stili del chiosco (kiosk) con variabili, stili per le schede (card) e animazioni.
• Aggiunto un file HTML per l'interfaccia della mappa utilizzando Mapbox, inclusi gli stili e il JavaScript per le funzionalità della mappa.
• Introdotto un file JSON per i riferimenti ai sensori, definendo percorsi ed elementi per i dati di temperatura, vento, onde, posizione, batteria, motore e sistema.

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
Giuseppe Raffa
2026-04-23 16:19:11 +02:00
parent 41f33ce181
commit bb8d267cd4
85 changed files with 4293 additions and 5083 deletions

View File

@@ -0,0 +1,29 @@
const { listDataFiles, buildPage } = require('../commands/backuplogs');
module.exports = {
prefix: 'bkback:',
handler: async (bot, query) => {
const chatId = query.message.chat.id;
const botMessageId = query.message.message_id;
const parts = query.data.split(':');
const page = parseInt(parts[1]);
const userMessageId = parts[2];
const files = await listDataFiles();
const keyboard = buildPage(files, page, userMessageId);
const text = `*Backup & Logs*\n\n${files.length} file disponibili nella cartella data.\n_Seleziona un file per info e download._`;
try {
await bot.editMessageText(text, {
chat_id: chatId,
message_id: botMessageId,
parse_mode: 'Markdown',
reply_markup: { inline_keyboard: keyboard }
});
} catch (e) {}
bot.answerCallbackQuery(query.id);
}
};

View File

@@ -0,0 +1,33 @@
const fs = require('fs');
const { listDataFiles } = require('../commands/backuplogs');
module.exports = {
prefix: 'bkdl:',
handler: async (bot, query) => {
const chatId = query.message.chat.id;
const parts = query.data.split(':');
const fileIdx = parseInt(parts[1]);
const userMessageId = parts[2];
const files = await listDataFiles();
const file = files[fileIdx];
if (!file || !fs.existsSync(file.path)) {
bot.answerCallbackQuery(query.id, { text: 'File non trovato', show_alert: true });
return;
}
try {
await bot.sendDocument(chatId, file.path, {
caption: `\`${file.name}\``,
parse_mode: 'Markdown'
});
} catch (e) {
bot.answerCallbackQuery(query.id, { text: 'Errore invio file', show_alert: true });
return;
}
bot.answerCallbackQuery(query.id, { text: 'File inviato' });
}
};

View File

@@ -0,0 +1,73 @@
const fs = require('fs');
const fsPromises = require('fs').promises;
const readline = require('readline');
const { listDataFiles, formatSize, buildPage } = require('../commands/backuplogs');
/**
* Conta le righe di un file in modo efficiente (stream)
*/
function countLines(filePath) {
return new Promise((resolve) => {
let count = 0;
const stream = fs.createReadStream(filePath);
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
rl.on('line', () => count++);
rl.on('close', () => resolve(count));
rl.on('error', () => resolve(-1));
});
}
module.exports = {
prefix: 'bkfile:',
handler: async (bot, query) => {
const chatId = query.message.chat.id;
const botMessageId = query.message.message_id;
const parts = query.data.split(':');
const fileIdx = parseInt(parts[1]);
const userMessageId = parts[2];
const files = await listDataFiles();
const file = files[fileIdx];
if (!file || !fs.existsSync(file.path)) {
bot.answerCallbackQuery(query.id, { text: 'File non trovato', show_alert: true });
return;
}
// Conta righe
let lineCount = '—';
try {
const ext = file.name.split('.').pop().toLowerCase();
if (['csv', 'txt', 'log', 'json'].includes(ext)) {
lineCount = await countLines(file.path);
}
} catch (e) {}
const modified = new Date(file.modified).toLocaleDateString('it-IT', {
day: '2-digit', month: 'long', year: 'numeric',
hour: '2-digit', minute: '2-digit'
});
const text = `*File:* \`${file.name}\`\n\n` +
`*Dimensione:* ${formatSize(file.size)}\n` +
`*Ultima modifica:* ${modified}\n` +
`*Righe:* ${lineCount}\n`;
const keyboard = [
[{ text: 'Scarica file', callback_data: `bkdl:${fileIdx}:${userMessageId}` }],
[{ text: '<- Torna alla lista', callback_data: `bkback:0:${userMessageId}` }]
];
try {
await bot.editMessageText(text, {
chat_id: chatId,
message_id: botMessageId,
parse_mode: 'Markdown',
reply_markup: { inline_keyboard: keyboard }
});
} catch (e) {}
bot.answerCallbackQuery(query.id);
}
};

View File

@@ -0,0 +1,6 @@
module.exports = {
prefix: 'bknoop:',
handler: async (bot, query) => {
bot.answerCallbackQuery(query.id);
}
};

View File

@@ -0,0 +1,29 @@
const { listDataFiles, buildPage } = require('../commands/backuplogs');
module.exports = {
prefix: 'bkpage:',
handler: async (bot, query) => {
const chatId = query.message.chat.id;
const botMessageId = query.message.message_id;
const parts = query.data.split(':');
const page = parseInt(parts[1]);
const userMessageId = parts[2];
const files = await listDataFiles();
const keyboard = buildPage(files, page, userMessageId);
const text = `*Backup & Logs*\n\n${files.length} file disponibili nella cartella data.\n_Seleziona un file per info e download._`;
try {
await bot.editMessageText(text, {
chat_id: chatId,
message_id: botMessageId,
parse_mode: 'Markdown',
reply_markup: { inline_keyboard: keyboard }
});
} catch (e) {}
bot.answerCallbackQuery(query.id);
}
};

View File

@@ -0,0 +1,25 @@
module.exports = {
prefix: 'close',
handler: async (bot, query) => {
const chatId = query.message.chat.id;
const botMessageId = query.message.message_id;
// L'ID del messaggio dell'utente è passato nel callback_data (close:<userMsgId>)
const userMessageId = query.data.split(':')[1];
try {
// Elimina il messaggio del bot
await bot.deleteMessage(chatId, botMessageId);
// Elimina il messaggio dell'utente (il comando /data)
if (userMessageId) {
await bot.deleteMessage(chatId, parseInt(userMessageId));
}
} catch (error) {
console.error('[TELEGRAM] Errore eliminazione messaggi:', error.message);
}
// Rispondi alla callback per togliere il "loading" dal bottone
bot.answerCallbackQuery(query.id);
}
};

View File

@@ -1,152 +0,0 @@
// Mappa globale per salvare gli interval id anche dopo un "hot-reload"
if (!global.__meb_live_dashboards) {
global.__meb_live_dashboards = new Map();
}
module.exports = [
{
id: 'dashboard-refresh',
execute: async ({ bot, chatId, msg }) => {
const dash = require('../commands/dashboard.js');
const newText = dash.formatSensorData();
try {
await bot.editMessageText(newText, {
chat_id: chatId,
message_id: msg.message_id,
parse_mode: 'Markdown',
reply_markup: {
inline_keyboard: [
[
{ text: "⚡️ Avvia Live Tracker (2s)", callback_data: 'dashboard-live-start' }
],
[
{ text: "🔄 Ricarica Dati", callback_data: 'dashboard-refresh' }
]
]
}
});
} catch (e) {
if (!e.message.includes('message is not modified')) {
console.error("Errore nel refresh dashboard:", e);
}
}
}
},
{
id: 'dashboard-live-start',
execute: async ({ bot, chatId, msg }) => {
const dash = require('../commands/dashboard.js');
const messageId = msg.message_id;
const liveKey = `${chatId}_${messageId}`;
// Se è già attivo un live per questo messaggio, non fare nulla
if (global.__meb_live_dashboards.has(liveKey)) return;
// Avvisa che sta partendo
const startMarkup = {
inline_keyboard: [
[
{ text: "🛑 Ferma Live Tracker", callback_data: 'dashboard-live-stop' }
]
]
};
await bot.editMessageReplyMarkup(startMarkup, { chat_id: chatId, message_id: messageId });
// Inizializza l'interval a 2 secondi. Autodistruzione dopo 30s
let count = 15; // 15 tick da 2 secondi = 30 secondi
const intervalTimer = setInterval(async () => {
count--;
const baseText = dash.formatSensorData();
// Se il tempo scade, disattiva il live e ripristina i tasti normali
if (count <= 0) {
if (global.__meb_live_dashboards.has(liveKey)) {
clearInterval(global.__meb_live_dashboards.get(liveKey));
global.__meb_live_dashboards.delete(liveKey);
}
try {
await bot.editMessageText(baseText + `\n🛑 _Live tracker terminato automaticamente (30s) per risparmiare risorse._`, {
chat_id: chatId,
message_id: messageId,
parse_mode: 'Markdown',
reply_markup: {
inline_keyboard: [
[{ text: "⚡️ Avvia Live Tracker (2s)", callback_data: 'dashboard-live-start' }],
[{ text: "🔄 Ricarica Dati", callback_data: 'dashboard-refresh' }]
]
}
});
} catch (e) { }
return;
}
// Altrimenti prosegui con l'aggiornamento e la stringa del countdown
const newText = baseText + `\n⏳ _Live attivo: arresto automatico tra *${count * 2}s*_`;
try {
await bot.editMessageText(newText, {
chat_id: chatId,
message_id: messageId,
parse_mode: 'Markdown',
reply_markup: startMarkup
});
} catch (e) {
// API limits o the message was not modified
if (e.response && e.response.statusCode === 400 && e.message.includes("message is not modified")) {
// ignore
} else if (e.response && e.response.statusCode === 429) {
// Troppe richieste Telegram
console.warn("[Telegram Dashboard] Rate Limit raggionto. Riprovo più tardi...");
} else if (e.response && e.response.statusCode === 400 && e.message.includes("message to edit not found")) {
// Il messaggio è stato cancellato dall'utente
clearInterval(intervalTimer);
global.__meb_live_dashboards.delete(liveKey);
} else {
console.error("[Telegram Dashboard] Errore update live:", e);
}
}
}, 2000);
global.__meb_live_dashboards.set(liveKey, intervalTimer);
}
},
{
id: 'dashboard-live-stop',
execute: async ({ bot, chatId, msg }) => {
const dash = require('../commands/dashboard.js');
const messageId = msg.message_id;
const liveKey = `${chatId}_${messageId}`;
// Pulisci l'interval se esiste
if (global.__meb_live_dashboards.has(liveKey)) {
clearInterval(global.__meb_live_dashboards.get(liveKey));
global.__meb_live_dashboards.delete(liveKey);
}
// Ripristina la formattazione iniziale
const newText = dash.formatSensorData();
try {
await bot.editMessageText(newText, {
chat_id: chatId,
message_id: messageId,
parse_mode: 'Markdown',
reply_markup: {
inline_keyboard: [
[
{ text: "⚡️ Avvia Live Tracker (2s)", callback_data: 'dashboard-live-start' }
],
[
{ text: "🔄 Ricarica Dati", callback_data: 'dashboard-refresh' }
]
]
}
});
} catch (e) { }
}
}
];

View File

@@ -1,26 +0,0 @@
module.exports = [
{
id: 'data-refresh',
execute: async ({ bot, chatId, msg }) => {
const dataCmd = require('../commands/data.js');
const newText = dataCmd.formatSensorData();
try {
await bot.editMessageText(newText, {
chat_id: chatId,
message_id: msg.message_id,
parse_mode: 'Markdown',
reply_markup: {
inline_keyboard: [
[{ text: 'Aggiorna', callback_data: 'data-refresh' }]
]
}
});
} catch (e) {
if (!e.message.includes('message is not modified')) {
console.error('[Telegram Data] Errore refresh:', e.message);
}
}
}
}
];

View File

@@ -1,141 +1,104 @@
// Mappa globale per salvare gli interval id anche dopo un "hot-reload"
if (!global.__meb_live_trackers) {
global.__meb_live_trackers = new Map();
}
const skFlow = require('../../config/skFlow');
const { startSession } = require('../utility/live');
module.exports = [
{
id: 'live-refresh',
execute: async ({ bot, chatId, msg }) => {
const liveCmd = require('../commands/live.js');
const newText = liveCmd.formatLiveData();
try {
await bot.editMessageText(newText, {
chat_id: chatId,
message_id: msg.message_id,
parse_mode: 'Markdown',
reply_markup: {
inline_keyboard: [
[{ text: 'Avvia Live (2s)', callback_data: 'live-start' }],
[{ text: 'Aggiorna', callback_data: 'live-refresh' }]
]
}
});
} catch (e) {
if (!e.message.includes('message is not modified')) {
console.error('[Telegram Live] Errore refresh:', e.message);
}
}
}
},
{
id: 'live-start',
execute: async ({ bot, chatId, msg }) => {
const liveCmd = require('../commands/live.js');
const messageId = msg.message_id;
const liveKey = `${chatId}_${messageId}`;
// Se gia' attivo per questo messaggio, ignora
if (global.__meb_live_trackers.has(liveKey)) return;
const stopMarkup = {
inline_keyboard: [
[{ text: 'Ferma Live', callback_data: 'live-stop' }]
]
};
await bot.editMessageReplyMarkup(stopMarkup, {
chat_id: chatId,
message_id: messageId
});
// 30 tick da 2 secondi = 60 secondi, poi auto-stop
let count = 30;
const intervalTimer = setInterval(async () => {
count--;
const baseText = liveCmd.formatLiveData();
// Auto-stop quando il tempo scade
if (count <= 0) {
if (global.__meb_live_trackers.has(liveKey)) {
clearInterval(global.__meb_live_trackers.get(liveKey));
global.__meb_live_trackers.delete(liveKey);
}
try {
await bot.editMessageText(
baseText + `\n_Live terminato automaticamente (60s)._`,
{
chat_id: chatId,
message_id: messageId,
parse_mode: 'Markdown',
reply_markup: {
inline_keyboard: [
[{ text: 'Avvia Live (2s)', callback_data: 'live-start' }],
[{ text: 'Aggiorna', callback_data: 'live-refresh' }]
]
}
}
);
} catch (e) { /* ignore */ }
return;
}
// Aggiornamento live con countdown
const newText = baseText + `\n_Live attivo: arresto tra *${count * 2}s*_`;
try {
await bot.editMessageText(newText, {
chat_id: chatId,
message_id: messageId,
parse_mode: 'Markdown',
reply_markup: stopMarkup
});
} catch (e) {
if (e.response && e.response.statusCode === 429) {
console.warn('[Telegram Live] Rate limit raggiunto');
} else if (e.message && e.message.includes('message to edit not found')) {
// Messaggio cancellato dall'utente
clearInterval(intervalTimer);
global.__meb_live_trackers.delete(liveKey);
}
// Ignora "message is not modified"
}
}, 2000);
global.__meb_live_trackers.set(liveKey, intervalTimer);
}
},
{
id: 'live-stop',
execute: async ({ bot, chatId, msg }) => {
const liveCmd = require('../commands/live.js');
const messageId = msg.message_id;
const liveKey = `${chatId}_${messageId}`;
// Pulisci l'interval se esiste
if (global.__meb_live_trackers.has(liveKey)) {
clearInterval(global.__meb_live_trackers.get(liveKey));
global.__meb_live_trackers.delete(liveKey);
}
const newText = liveCmd.formatLiveData();
try {
await bot.editMessageText(newText + '\n_Live fermato._', {
chat_id: chatId,
message_id: messageId,
parse_mode: 'Markdown',
reply_markup: {
inline_keyboard: [
[{ text: 'Avvia Live (2s)', callback_data: 'live-start' }],
[{ text: 'Aggiorna', callback_data: 'live-refresh' }]
]
}
});
} catch (e) { /* ignore */ }
}
}
const logsPaths = [
"navigation.position",
"navigation.headingTrue",
"navigation.speedOverGround",
"propulsion.p1.temperature"
];
// Funzioni per generare il testo aggiornato per ogni tipo di dato
const textGenerators = {
logs: () => {
const data = skFlow.getFrom(logsPaths);
if (!data || Object.keys(data).length === 0) return 'Nessun log disponibile.';
let text = '*Telemetria di Bordo*\n\n';
for (const [path, value] of Object.entries(data)) {
const displayValue = typeof value === 'object' ? JSON.stringify(value) : value;
text += `*${path}*: ${displayValue}\n`;
}
return text;
},
weather: () => {
const data = skFlow.getWithFilter('meb.forecast');
if (!data || Object.keys(data).length === 0) return 'Nessun dato meteo disponibile.';
let text = '*Dati Meteo*\n\n';
for (const [path, value] of Object.entries(data)) {
const displayValue = typeof value === 'object' ? JSON.stringify(value) : value;
text += `*${path}*: ${displayValue}\n`;
}
return text;
},
marine: () => {
const data = skFlow.getWithFilter('meb.marine');
if (!data || Object.keys(data).length === 0) return 'Nessun dato sul mare disponibile.';
let text = '*Dati Meteo del mare*\n\n';
for (const [path, value] of Object.entries(data)) {
const displayValue = typeof value === 'object' ? JSON.stringify(value) : value;
text += `*${path}*: ${displayValue}\n`;
}
return text;
},
data: () => {
let text = '';
const logs = skFlow.getFrom(logsPaths);
text += '*Telemetria di Bordo*\n\n';
if (logs && Object.keys(logs).length > 0) {
for (const [path, value] of Object.entries(logs)) {
const displayValue = typeof value === 'object' ? JSON.stringify(value) : value;
text += `*${path}*: ${displayValue}\n`;
}
} else {
text += 'Nessun dato disponibile.\n';
}
const weather = skFlow.getWithFilter('meb.forecast');
text += '\n*Dati Meteo*\n\n';
if (weather && Object.keys(weather).length > 0) {
for (const [path, value] of Object.entries(weather)) {
const displayValue = typeof value === 'object' ? JSON.stringify(value) : value;
text += `*${path}*: ${displayValue}\n`;
}
} else {
text += 'Nessun dato disponibile.\n';
}
const marine = skFlow.getWithFilter('meb.marine');
text += '\n*Dati Meteo del mare*\n\n';
if (marine && Object.keys(marine).length > 0) {
for (const [path, value] of Object.entries(marine)) {
const displayValue = typeof value === 'object' ? JSON.stringify(value) : value;
text += `*${path}*: ${displayValue}\n`;
}
} else {
text += 'Nessun dato disponibile.\n';
}
return text;
}
};
module.exports = {
prefix: 'live:',
handler: async (bot, query) => {
const chatId = query.message.chat.id;
const botMessageId = query.message.message_id;
// callback_data = live:<dataType>:<userMessageId>
const parts = query.data.split(':');
const dataType = parts[1];
const userMessageId = parts[2];
const getTextFn = textGenerators[dataType];
if (!getTextFn) {
bot.answerCallbackQuery(query.id, { text: 'Tipo non supportato' });
return;
}
startSession(bot, chatId, botMessageId, userMessageId, getTextFn);
bot.answerCallbackQuery(query.id, { text: 'Live avviato' });
},
textGenerators
};

View File

@@ -0,0 +1,15 @@
const { stopSession, getSession } = require('../utility/live');
module.exports = {
prefix: 'livestop',
handler: async (bot, query) => {
const chatId = query.message.chat.id;
const botMessageId = query.message.message_id;
// callback_data = livestop:<userMessageId>
const userMessageId = query.data.split(':')[1];
await stopSession(bot, chatId, botMessageId, userMessageId);
bot.answerCallbackQuery(query.id);
}
};

View File

@@ -0,0 +1,12 @@
module.exports = {
prefix: 'logbusy:',
handler: async (bot, query) => {
const logName = query.data.split(':')[1];
// Mostra un alert all'utente che il file non si puo scaricare
bot.answerCallbackQuery(query.id, {
text: `La registrazione "${logName}" è in corso. Fermala per scaricare il file.`,
show_alert: true
});
}
};

View File

@@ -0,0 +1,69 @@
const recorder = require('../../cores/logs.local');
module.exports = {
prefix: 'logfile:',
handler: async (bot, query) => {
const chatId = query.message.chat.id;
const listMessageId = query.message.message_id;
// callback_data = logfile:<name>:<userMessageId>
const parts = query.data.split(':');
const logName = parts[1];
const userMessageId = parts[2];
// Elimina il messaggio con la lista dei file
try {
await bot.deleteMessage(chatId, listMessageId);
} catch (e) {}
// Ottieni il file e le sue informazioni
const filePath = recorder.getLogFile(logName);
if (!filePath) {
bot.answerCallbackQuery(query.id, { text: 'File non trovato' });
return;
}
// Controllo aggiuntivo: se il file è quello in registrazione attiva
const session = recorder.getSession();
if (session && session.name === logName) {
bot.answerCallbackQuery(query.id, {
text: `Il file "${logName}" è attualmente in uso per la registrazione attiva. Fermala per scaricarlo.`,
show_alert: true
});
return;
}
// Ottieni info del file
const fs = require('fs');
const stat = fs.statSync(filePath);
const sizeMB = (stat.size / (1024 * 1024)).toFixed(2);
const created = new Date(stat.birthtime).toLocaleDateString('it-IT', {
day: '2-digit', month: '2-digit', year: 'numeric',
hour: '2-digit', minute: '2-digit'
});
const caption = `*CSV\nCreato: ${created}\ ${sizeMB} MB`;
// Invia il file
const docMessage = await bot.sendDocument(chatId, filePath, {
caption: caption,
parse_mode: 'Markdown'
});
// Elimina il messaggio dell'utente (il comando /logs)
try {
if (userMessageId) {
await bot.deleteMessage(chatId, parseInt(userMessageId));
}
} catch (e) {}
// Dopo 5 secondi, elimina il messaggio con il documento
setTimeout(async () => {
try {
await bot.deleteMessage(chatId, docMessage.message_id);
} catch (e) {}
}, 10000); //dopo 10 secondi
bot.answerCallbackQuery(query.id);
}
};

View File

@@ -1,51 +0,0 @@
const realtime = require('../../realtime/core.js');
const { config } = require('../../config.js');
module.exports = [
{
id: 'logs-refresh',
execute: async ({ bot, chatId, msg }) => {
const stats = realtime.getStats();
const consoleUrl = config.cloudUrl || 'https://console.mebboat.it';
let statusIcon = '🔴';
if (stats.status === 'connected') statusIcon = '🟢';
else if (stats.status === 'error') statusIcon = '🟡';
const now = new Date().toLocaleTimeString('it-IT');
let text = `📊 *Registrazione Dati Realtime*\n\n`;
text += `Stato: ${statusIcon} *${stats.status}*\n`;
text += `Sensore: \`${stats.sensorID}\`\n`;
text += `Messaggi inviati: *${stats.sent}*\n`;
text += `Frequenza: ogni *${stats.sentEveryMLS / 1000}s*\n`;
if (stats.buffered > 0) {
text += `⚠️ Messaggi in buffer: *${stats.buffered}*\n`;
}
if (stats.reconnections > 0) {
text += `Riconnessioni: ${stats.reconnections}\n`;
}
text += `\n_(Aggiornato: ${now})_`;
try {
await bot.editMessageText(text, {
chat_id: chatId,
message_id: msg.message_id,
parse_mode: 'Markdown',
reply_markup: {
inline_keyboard: [
[{ text: '📈 Apri Console Log', url: `${consoleUrl}/logs` }],
[{ text: '🔄 Aggiorna Stato', callback_data: 'logs-refresh' }]
]
}
});
} catch (e) {
if (!e.message.includes('message is not modified')) {
console.error("[Telegram] Errore refresh logs:", e);
}
}
}
}
];

View File

@@ -1,80 +0,0 @@
module.exports = [
{
id: 'set-meteo',
execute: async ({ bot, chatId, app }) => {
const config = app.mebConfig;
const currentFreqMin = config.forecast_current_frequency / 60000;
const hourlyFreqMin = config.forecast_hourly_frequency / 60000;
const msg = `*Configura Aggiornamenti Meteo*\n\n` +
`Aggiorno il meteo (attuale) ogni *${currentFreqMin} minuti*\n` +
`Registro le previsioni future (prossimi 7 giorni) ogni *${hourlyFreqMin} minuti*`;
await bot.sendMessage(chatId, msg, {
parse_mode: 'Markdown',
reply_markup: {
inline_keyboard: [
[
{ text: "1 sec", callback_data: 'set-meteo-curr-1' },
{ text: "10 sec", callback_data: 'set-meteo-curr-10' },
],
[
{ text: "1 min", callback_data: 'set-meteo-curr-60' },
{ text: "10 min", callback_data: 'set-meteo-curr-600' }
],
[
{ text: "30m", callback_data: 'set-meteo-hour-1800' }
],
[
{ text: "⬅️ Indietro", callback_data: 'session-refresh' }
]
]
}
});
}
},
{
match: (data) => data.startsWith('set-meteo-curr-'),
execute: async ({ bot, chatId, app, data, msg }) => {
const val = parseInt(data.replace('set-meteo-curr-', ''), 10);
if (app.mebPlugin && app.mebPlugin.setConfig) {
app.mebPlugin.setConfig('forecast_current_frequency', val);
await bot.editMessageText(`✅ Frequenza Aggiornamenti meteo aggiornata a *${val / 60} minuti*.\n_Ritorno al menu..._`, {
chat_id: chatId,
message_id: msg.message_id,
parse_mode: 'Markdown'
});
setTimeout(() => {
const sessionCmd = require('../commands/status.js');
bot.editMessageText("*Servizi*\n\n", {
chat_id: chatId, message_id: msg.message_id, parse_mode: 'Markdown', reply_markup: sessionCmd.createSessionMenu(app).reply_markup
}).catch(() => { });
}, 3000);
} else {
await bot.sendMessage(chatId, "Errore: il plugin non è accessibile per salvare la configurazione.");
}
}
},
{
match: (data) => data.startsWith('set-meteo-hour-'),
execute: async ({ bot, chatId, app, data, msg }) => {
const val = parseInt(data.replace('set-meteo-hour-', ''), 10);
if (app.mebPlugin && app.mebPlugin.setConfig) {
app.mebPlugin.setConfig('forecast_hourly_frequency', val);
await bot.editMessageText(`✅ Frequenza previsioni future aggiornata a *${val / 60} minuti*.\n_Ritorno al menu..._`, {
chat_id: chatId,
message_id: msg.message_id,
parse_mode: 'Markdown'
});
setTimeout(() => {
const sessionCmd = require('../commands/status.js');
bot.editMessageText("*Servizi*\n\n", {
chat_id: chatId, message_id: msg.message_id, parse_mode: 'Markdown', reply_markup: sessionCmd.createSessionMenu(app).reply_markup
}).catch(() => { });
}, 3000);
} else {
await bot.sendMessage(chatId, "Errore: il plugin non è accessibile per salvare la configurazione.");
}
}
}
];

View File

@@ -1,67 +0,0 @@
const realtime = require('../../realtime/core.js');
module.exports = [
{
id: 'session-weather-toggle',
execute: async ({ bot, chatId, app, msg }) => {
if (!app.mebPlugin) {
return bot.answerCallbackQuery(msg.id, { text: "Errore: Plugin Meteo non caricato" });
}
let isActive = app.mebPlugin.isPollingActive();
if (isActive) {
app.mebPlugin.stopPolling();
} else {
app.mebPlugin.startPolling();
}
const sessionCmd = require('../commands/status.js');
const newMarkup = sessionCmd.createSessionMenu(app);
await bot.editMessageReplyMarkup(newMarkup.reply_markup, {
chat_id: chatId,
message_id: msg.message_id
});
}
},
{
id: 'session-realtime-info',
execute: async ({ bot, chatId, msg }) => {
const stats = realtime.getStats();
let text = `📡 *Stato Realtime*\n\n`;
text += `Stato: *${stats.status}*\n`;
text += `Sensore: \`${stats.sensorID}\`\n`;
text += `Messaggi inviati: *${stats.sent}*\n`;
text += `Buffer: ${stats.buffered} msg\n`;
text += `Riconnessioni: ${stats.reconnections}\n`;
text += `\n_I dati vengono inviati automaticamente ogni ${stats.sentEveryMLS / 1000}s_`;
await bot.answerCallbackQuery(msg.id, { text: `Realtime: ${stats.status} | ${stats.sent} msg inviati` });
}
},
{
id: 'session-refresh',
execute: async ({ bot, chatId, app, msg }) => {
const sessionCmd = require('../commands/status.js');
const newMarkup = sessionCmd.createSessionMenu(app);
const now = new Date().toLocaleTimeString('it-IT');
const newText = `*Servizi*\n\n_(Ultimo aggiornamento: ${now})_`;
try {
await bot.editMessageText(newText, {
chat_id: chatId,
message_id: msg.message_id,
parse_mode: 'Markdown',
reply_markup: newMarkup.reply_markup
});
} catch (e) {
if (!e.message.includes('message is not modified')) {
console.error("Errore nel refresh session:", e);
}
}
}
}
];

View File

@@ -1,26 +0,0 @@
module.exports = [
{
id: 'weather-refresh',
execute: async ({ bot, chatId, msg }) => {
const weather = require('../commands/weather.js');
const newText = weather.formatWeatherData();
try {
await bot.editMessageText(newText, {
chat_id: chatId,
message_id: msg.message_id,
parse_mode: 'Markdown',
reply_markup: {
inline_keyboard: [
[{ text: 'Aggiorna', callback_data: 'weather-refresh' }]
]
}
});
} catch (e) {
if (!e.message.includes('message is not modified')) {
console.error('[Telegram Weather] Errore refresh:', e.message);
}
}
}
}
];

View File

@@ -0,0 +1,110 @@
const fs = require('fs').promises;
const fsSync = require('fs');
const pth = require('path');
const { closeButton } = require('../utility/close');
const dataDir = pth.join(__dirname, '../../../data/');
const PAGE_SIZE = 5;
/**
* Raccoglie ricorsivamente tutti i file nella cartella data/
*/
async function listDataFiles() {
const results = [];
async function scan(dir, prefix) {
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = pth.join(dir, entry.name);
if (entry.isDirectory()) {
await scan(fullPath, prefix ? `${prefix}/${entry.name}` : entry.name);
} else {
const stat = await fs.stat(fullPath);
results.push({
name: prefix ? `${prefix}/${entry.name}` : entry.name,
path: fullPath,
size: stat.size,
modified: stat.mtime,
lines: null // calcolato on-demand
});
}
}
}
try {
await scan(dataDir, '');
} catch (e) {
console.error('[BACKUP] Errore scan:', e.message);
}
return results.sort((a, b) => b.modified - a.modified);
}
function formatSize(bytes) {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
}
function buildPage(files, page, userMessageId) {
const totalPages = Math.ceil(files.length / PAGE_SIZE);
const start = page * PAGE_SIZE;
const pageFiles = files.slice(start, start + PAGE_SIZE);
const keyboard = pageFiles.map(f => {
const date = new Date(f.modified).toLocaleDateString('it-IT', {
day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit'
});
const label = `${f.name} (${formatSize(f.size)}, ${date})`;
// Encode file name as base64-safe identifier (index in full list)
const fileIdx = files.indexOf(f);
return [{ text: label, callback_data: `bkfile:${fileIdx}:${userMessageId}` }];
});
// Navigation row
const navRow = [];
if (page > 0) {
navRow.push({ text: '<< Prec', callback_data: `bkpage:${page - 1}:${userMessageId}` });
}
navRow.push({ text: `${page + 1}/${totalPages}`, callback_data: `bknoop:0` });
if (page < totalPages - 1) {
navRow.push({ text: 'Succ >>', callback_data: `bkpage:${page + 1}:${userMessageId}` });
}
keyboard.push(navRow);
// Close button
keyboard.push([{ text: '<- Chiudi', callback_data: `close:${userMessageId}` }]);
return keyboard;
}
module.exports = {
command: 'backup',
handler: async (bot, msg) => {
const chatId = msg.chat.id;
const files = await listDataFiles();
if (files.length === 0) {
bot.sendMessage(chatId, 'Nessun file nella cartella data.', {
reply_to_message_id: msg.message_id,
reply_markup: closeButton(msg.message_id)
});
return;
}
const text = `*Backup & Logs*\n\n${files.length} file disponibili nella cartella data.\n_Seleziona un file per info e download._`;
const keyboard = buildPage(files, 0, msg.message_id);
bot.sendMessage(chatId, text, {
parse_mode: 'Markdown',
reply_to_message_id: msg.message_id,
reply_markup: { inline_keyboard: keyboard }
});
},
// Export utilities for callbacks
listDataFiles,
formatSize,
buildPage,
PAGE_SIZE
};

View File

@@ -1,57 +0,0 @@
const dataHub = require('../../tools/dataHub');
function formatSensorData() {
const sensorSnapshot = dataHub.getSensorData();
const data = { timestamp: new Date().toISOString(), ...(sensorSnapshot || {}) };
let output = `📊 *Dashboard Sensori*\n`;
output += `_Ultimo aggiornamento: ${new Date().toLocaleTimeString('it-IT')}_\n\n`;
let isDataEmpty = true;
for (const [key, value] of Object.entries(data)) {
if (key === 'timestamp') continue;
isDataEmpty = false;
let formattedKey = key.replace(/_/g, ' ');
// Prima lettera maiuscola
formattedKey = formattedKey.charAt(0).toUpperCase() + formattedKey.slice(1);
const formattedValue = (value !== null && value !== undefined)
? (typeof value === 'number' ? value.toFixed(2) : value)
: 'N/A';
output += `🔹 *${formattedKey}:* ${formattedValue}\n`;
}
if (isDataEmpty) {
output += `_Nessun dato configurato o letto. Controlla sensors.references.json_\n`;
}
return output;
}
module.exports = {
command: 'dashboard',
description: 'Mostra i sensori live (dal file references)',
pattern: /\/dashboard/,
execute: async (bot, msg) => {
const chatId = msg.chat.id;
const text = formatSensorData();
await bot.sendMessage(chatId, text, {
parse_mode: 'Markdown',
reply_markup: {
inline_keyboard: [
[
{ text: "⚡️ Avvia Live Tracker (2s)", callback_data: 'dashboard-live-start' }
],
[
{ text: "🔄 Ricarica Dati", callback_data: 'dashboard-refresh' }
]
]
}
});
},
formatSensorData // Esportato per riuso nel refresh e nel live
};

View File

@@ -1,58 +1,59 @@
const dataHub = require('../../tools/dataHub');
const skFlow = require('../../config/skFlow');
const { liveMarkup } = require('../utility/live');
/**
* Formatta i dati sensore in un messaggio Telegram leggibile.
* @returns {string} Testo formattato Markdown
*/
function formatSensorData() {
const sensors = dataHub.getSensorData();
if (!sensors) {
return 'Nessun dato sensore disponibile.\nI sensori potrebbero non essere ancora attivi.';
}
let text = '*Dati Sensori*\n';
text += `_${new Date().toLocaleTimeString('it-IT')}_\n\n`;
let hasData = false;
for (const [key, value] of Object.entries(sensors)) {
if (key.startsWith('_')) continue; // Skip campi interni
hasData = true;
let label = key.replace(/_/g, ' ');
label = label.charAt(0).toUpperCase() + label.slice(1);
const formatted = (value !== null && value !== undefined)
? (typeof value === 'number' ? value.toFixed(2) : String(value))
: 'N/A';
text += `*${label}:* ${formatted}\n`;
}
if (!hasData) {
text += '_Nessun dato configurato. Controlla sensors.references.json_\n';
}
return text;
}
const logsPaths = [
"navigation.position",
"navigation.headingTrue",
"navigation.speedOverGround",
"propulsion.p1.temperature"
];
module.exports = {
command: 'data',
description: 'Mostra i dati sensori attuali',
pattern: /\/data/,
execute: async (bot, msg) => {
handler: (bot, msg) => {
const chatId = msg.chat.id;
const text = formatSensorData();
let text = '';
await bot.sendMessage(chatId, text, {
parse_mode: 'Markdown',
reply_markup: {
inline_keyboard: [
[{ text: 'Aggiorna', callback_data: 'data-refresh' }]
]
// Telemetria
const logs = skFlow.getFrom(logsPaths);
text += '*Telemetria di Bordo*\n\n';
if (logs && Object.keys(logs).length > 0) {
for (const [path, value] of Object.entries(logs)) {
const displayValue = typeof value === 'object' ? JSON.stringify(value) : value;
text += `*${path}*: ${displayValue}\n`;
}
} else {
text += 'Nessun dato disponibile.\n';
}
// Meteo
const weather = skFlow.getWithFilter('meb.forecast');
text += '\n*Dati Meteo*\n\n';
if (weather && Object.keys(weather).length > 0) {
for (const [path, value] of Object.entries(weather)) {
const displayValue = typeof value === 'object' ? JSON.stringify(value) : value;
text += `*${path}*: ${displayValue}\n`;
}
} else {
text += 'Nessun dato disponibile.\n';
}
// Mare
const marine = skFlow.getWithFilter('meb.marine');
text += '\n*Dati Meteo del mare*\n\n';
if (marine && Object.keys(marine).length > 0) {
for (const [path, value] of Object.entries(marine)) {
const displayValue = typeof value === 'object' ? JSON.stringify(value) : value;
text += `*${path}*: ${displayValue}\n`;
}
} else {
text += 'Nessun dato disponibile.\n';
}
bot.sendMessage(chatId, text, {
parse_mode: 'Markdown',
reply_to_message_id: msg.message_id,
reply_markup: liveMarkup(msg.message_id, 'data')
});
},
formatSensorData
}
};

View File

@@ -1,84 +0,0 @@
const dataHub = require('../../tools/dataHub');
/**
* Formatta tutti i dati (sensori + meteo) per il live tracker.
* @returns {string} Testo formattato Markdown
*/
function formatLiveData() {
const sensors = dataHub.getSensorData();
const { forecast, sea } = dataHub.getWeatherData();
let text = '*LIVE - Dati Completi*\n';
text += `_${new Date().toLocaleTimeString('it-IT')}_\n\n`;
// Sezione sensori
if (sensors) {
text += '*Sensori:*\n';
for (const [key, value] of Object.entries(sensors)) {
if (key.startsWith('_')) continue;
let label = key.replace(/_/g, ' ');
label = label.charAt(0).toUpperCase() + label.slice(1);
const val = (value !== null && value !== undefined)
? (typeof value === 'number' ? value.toFixed(2) : String(value))
: 'N/A';
text += ` ${label}: ${val}\n`;
}
} else {
text += '_Nessun dato sensore disponibile_\n';
}
// Sezione meteo (compatta)
if (forecast) {
text += '\n*Meteo:*\n';
const parts = [];
if (forecast.temperature !== null && forecast.temperature !== undefined) {
parts.push(`Temp: ${forecast.temperature}C`);
}
if (forecast.humidity !== null && forecast.humidity !== undefined) {
parts.push(`Um: ${forecast.humidity}%`);
}
if (forecast.wind?.speed !== null && forecast.wind?.speed !== undefined) {
parts.push(`Vento: ${forecast.wind.speed}km/h`);
}
text += ` ${parts.join(' | ')}\n`;
}
if (sea?.waves) {
const seaParts = [];
if (sea.waves.height !== null && sea.waves.height !== undefined) {
seaParts.push(`Onde: ${sea.waves.height}m`);
}
if (sea.waves.period !== null && sea.waves.period !== undefined) {
seaParts.push(`Per: ${sea.waves.period}s`);
}
if (seaParts.length > 0) {
text += ` ${seaParts.join(' | ')}\n`;
}
}
return text;
}
module.exports = {
command: 'live',
description: 'Dati live (meteo + sensori) con aggiornamento automatico',
pattern: /\/live/,
execute: async (bot, msg) => {
const chatId = msg.chat.id;
const text = formatLiveData();
await bot.sendMessage(chatId, text, {
parse_mode: 'Markdown',
reply_markup: {
inline_keyboard: [
[{ text: 'Avvia Live (2s)', callback_data: 'live-start' }],
[{ text: 'Aggiorna', callback_data: 'live-refresh' }]
]
}
});
},
formatLiveData
};

View File

@@ -1,53 +1,53 @@
const realtime = require('../../realtime/core.js');
const { config } = require('../../config.js');
const recorder = require('../../cores/logs.local');
const { closeButton } = require('../utility/close');
module.exports = {
command: 'logs',
description: 'Mostra lo stato della registrazione dati in tempo reale',
pattern: /\/logs/,
execute: async (bot, msg, { app }) => {
handler: async (bot, msg) => {
const chatId = msg.chat.id;
try {
const stats = realtime.getStats();
const consoleUrl = config.cloudUrl || 'https://console.mebboat.it';
const logs = await recorder.listLogs();
let statusIcon = '🔴';
if (stats.status === 'connected') statusIcon = '🟢';
else if (stats.status === 'error') statusIcon = '🟡';
let text = `📊 *Registrazione Dati Realtime*\n\n`;
text += `Stato: ${statusIcon} *${stats.status}*\n`;
text += `Sensore: \`${stats.sensorID}\`\n`;
text += `Messaggi inviati: *${stats.sent}*\n`;
text += `Frequenza: ogni *${stats.sentEveryMLS / 1000}s*\n`;
if (stats.buffered > 0) {
text += `⚠️ Messaggi in buffer: *${stats.buffered}*\n`;
}
if (stats.reconnections > 0) {
text += `Riconnessioni: ${stats.reconnections}\n`;
}
if (stats.firstSent) {
text += `\nPrimo invio: ${stats.firstSent}\n`;
}
text += `\n_I dati vengono inviati automaticamente al server ogni secondo._`;
text += `\n_Consulta i log storici sulla console:_`;
await bot.sendMessage(chatId, text, {
parse_mode: 'Markdown',
reply_markup: {
inline_keyboard: [
[{ text: '📈 Apri Console Log', url: `${consoleUrl}/logs` }],
[{ text: '🔄 Aggiorna Stato', callback_data: 'logs-refresh' }]
]
}
if (!logs || logs.length === 0) {
bot.sendMessage(chatId, 'Nessun file di log disponibile.', {
reply_to_message_id: msg.message_id,
reply_markup: closeButton(msg.message_id)
});
} catch (error) {
console.error("[Telegram] Errore comando /logs:", error);
bot.sendMessage(chatId, `❌ Errore: ${error.message}`);
return;
}
const session = recorder.getSession();
let text = '*Registrazioni dei Log*\n\n';
if (session) {
text += `in corso: *${session.name}*\n`;
text += `${session.elements} dati raccolti ogni ${session.delay}s\n\n`;
}
text += `${logs.length} file disponibili:\n`;
text += '_Selezionane uno per scaricarlo_';
// Bottoni per ogni file
const keyboard = logs.map(log => {
const date = new Date(log.created).toLocaleDateString('it-IT', {
day: '2-digit', month: '2-digit', year: '2-digit',
hour: '2-digit', minute: '2-digit'
});
const isActive = session && session.name === log.name;
const label = isActive ? `🔴 ${log.name} *[IN CORSO, NON DISPONIBILE]*` : `${date})`;
const callback = isActive ? `logbusy:${log.name}` : `logfile:${log.name}:${msg.message_id}`;
return [{ text: label, callback_data: callback }];
});
// Aggiungi il bottone chiudi
keyboard.push([{ text: '<- Chiudi', callback_data: `close:${msg.message_id}` }]);
bot.sendMessage(chatId, text, {
parse_mode: 'Markdown',
reply_to_message_id: msg.message_id,
reply_markup: { inline_keyboard: keyboard }
});
}
};

View File

@@ -0,0 +1,30 @@
const skFlow = require('../../config/skFlow');
const { liveMarkup } = require('../utility/live');
module.exports = {
command: 'marine',
handler: (bot, msg) => {
const chatId = msg.chat.id;
const data = skFlow.getWithFilter('meb.marine');
let text = '';
if (!data || Object.keys(data).length === 0) {
text = 'Nessun dato sul mare disponibile.';
} else {
text = '*Dati Meteo del mare*\n\n';
for (const [path, value] of Object.entries(data)) {
const displayValue = typeof value === 'object' ? JSON.stringify(value) : value;
//TODO: ADD units
//TODO: Formattare meglio i path
text += `*${path}*: ${displayValue}\n`;
}
}
bot.sendMessage(chatId, text, {
parse_mode: 'Markdown',
reply_to_message_id: msg.message_id,
reply_markup: liveMarkup(msg.message_id, 'marine')
});
}
};

View File

@@ -1,24 +0,0 @@
const realtime = require('../../realtime/core.js');
module.exports = {
command: 'realtime',
description: 'Dettagli della connessione realtime',
pattern: /\/realtime/,
execute: async (bot, msg) => {
const stats = realtime.getStats();
const statusEmoji = stats.status === 'connected' ? '🟢' : '🔴';
let message = `*Connessione Realtime* ${statusEmoji}\n\n`;
message += `*ID Sensore:* ${stats.sensorID}\n`;
message += `*Stato:* ${stats.status}\n`;
message += `*Messaggi inviati:* ${stats.sent}\n`;
message += `*Riconnessioni:* ${stats.reconnections}\n`;
message += `*Frequenza:* ${stats.sentEveryMLS}ms\n`;
if (stats.firstSent) {
message += `*Primo invio:* ${new Date(stats.firstSent).toLocaleString()}\n`;
}
await bot.sendMessage(msg.chat.id, message, { parse_mode: 'Markdown' });
}
};

View File

@@ -1,20 +0,0 @@
module.exports = {
command: 'settings',
description: 'Mostra le impostazioni del Computer di Bordo',
pattern: /\/settings/,
execute: async (bot, msg) => {
const chatId = msg.chat.id;
await bot.sendMessage(chatId, "*Configurazione Computer di Bordo*\nScegli quali parametri modificare:", {
parse_mode: 'Markdown',
reply_markup: {
inline_keyboard: [
[
{ text: "Meteo", callback_data: 'set-meteo' },
{ text: "Batterie", callback_data: 'set-batteries' }
]
]
}
});
}
};

View File

@@ -0,0 +1,17 @@
module.exports = {
command: 'start',
handler: (bot, msg) => {
const chatId = msg.chat.id;
bot.setMyCommands([
{ command: 'data', description: 'Mostra tutti i dati' },
{ command: 'logs', description: 'Registrazioni logs' },
{ command: 'weather', description: 'Mostra i dati meteo' },
{ command: 'marine', description: 'Mostra i dati del mare' },
{ command: 'backup', description: 'Backup logs - lista file nella cartella data' },
// { command: 'start', description: 'Avvia il bot e configura i comandi' }
]);
bot.sendMessage(chatId, 'Benvenuto nel bot MEB!');
}
};

View File

@@ -1,40 +0,0 @@
const realtime = require('../../realtime/core.js');
function createSessionMenu(app) {
const weatherActive = app.mebPlugin && app.mebPlugin.isPollingActive ? app.mebPlugin.isPollingActive() : false;
const realtimeStats = realtime.getStats();
const realtimeConnected = realtimeStats.isConnected;
return {
reply_markup: {
inline_keyboard: [
[
{ text: weatherActive ? "Meteo: 🟢 ON (Premi per fermare)" : "Meteo: 🔴 OFF (Premi per avviare)", callback_data: 'session-weather-toggle' }
],
[
{ text: realtimeConnected ? "Realtime: 🟢 Connesso" : "Realtime: 🔴 Disconnesso", callback_data: 'session-realtime-info' }
],
[
{ text: "🔄", callback_data: 'session-refresh' },
{ text: "⚙️ ⛅️ (meteo)", callback_data: 'set-meteo' }
]
]
}
};
}
module.exports = {
command: 'session',
description: 'Verifica le attività di Meteo e Realtime',
pattern: /\/session/,
execute: async (bot, msg, { app }) => {
const chatId = msg.chat.id;
const msgText = `*Servizi*\n\n`;
await bot.sendMessage(chatId, msgText, {
parse_mode: 'Markdown',
...createSessionMenu(app)
});
},
createSessionMenu
};

View File

@@ -1,47 +0,0 @@
const realtime = require('../../realtime/core.js');
module.exports = {
command: 'structure',
description: 'Mostra la struttura dati del plugin',
pattern: /\/structure/,
execute: async (bot, msg) => {
const chatId = msg.chat.id;
const rules = realtime.getSensorRules();
if (!rules) {
return bot.sendMessage(chatId, 'Nessuna configurazione sensori caricata.');
}
let text = `*Struttura Dati Plugin*\n`;
text += `Versione: \`${rules.version}\`\n`;
text += `Attivo: ${rules.isActive ? 'Si' : 'No'}\n`;
text += `Collezioni: ${rules.items?.length || 0}\n\n`;
if (rules.items) {
for (const item of rules.items) {
text += `*${item.collection}*\n`;
text += ` Path: \`${item.main_path}\`\n`;
if (item.elements && Array.isArray(item.elements)) {
for (const element of item.elements) {
const { subelements, ...fields } = element;
const [name, subPath] = Object.entries(fields)[0];
text += ` - ${name} -> \`${item.main_path}.${subPath}\`\n`;
if (subelements && Array.isArray(subelements)) {
for (const sub of subelements) {
const [sName, sPath] = Object.entries(sub)[0];
text += ` - ${sName} -> \`${item.main_path}.${subPath}.${sPath}\`\n`;
}
}
}
} else {
text += ` (valore singolo)\n`;
}
text += `\n`;
}
}
await bot.sendMessage(chatId, text, { parse_mode: 'Markdown' });
}
};

View File

@@ -1,84 +1,29 @@
const dataHub = require('../../tools/dataHub');
/**
* Formatta i dati meteo in un messaggio Telegram leggibile.
* @returns {string} Testo formattato Markdown
*/
function formatWeatherData() {
const { forecast, sea } = dataHub.getWeatherData();
if (!forecast && !sea) {
return 'Nessun dato meteo disponibile.\nIl polling potrebbe non essere ancora partito.';
}
let text = '*Meteo Attuale*\n';
text += `_${new Date().toLocaleTimeString('it-IT')}_\n\n`;
if (forecast) {
if (forecast.temperature !== null && forecast.temperature !== undefined) {
text += `Temperatura: *${forecast.temperature}*C\n`;
}
if (forecast.humidity !== null && forecast.humidity !== undefined) {
text += `Umidita: *${forecast.humidity}*%\n`;
}
if (forecast.pressure !== null && forecast.pressure !== undefined) {
text += `Pressione: *${forecast.pressure}* hPa\n`;
}
if (forecast.rain !== null && forecast.rain !== undefined) {
text += `Pioggia: *${forecast.rain}* mm\n`;
}
if (forecast.wind) {
text += `\nVento:\n`;
if (forecast.wind.speed !== null && forecast.wind.speed !== undefined) {
text += ` Velocita: *${forecast.wind.speed}* km/h\n`;
}
if (forecast.wind.direction !== null && forecast.wind.direction !== undefined) {
text += ` Direzione: *${forecast.wind.direction}*\n`;
}
if (forecast.wind.gusts !== null && forecast.wind.gusts !== undefined) {
text += ` Raffiche: *${forecast.wind.gusts}* km/h\n`;
}
}
}
if (sea) {
text += `\nMare:\n`;
if (sea.waves) {
if (sea.waves.height !== null && sea.waves.height !== undefined) {
text += ` Altezza onde: *${sea.waves.height}* m\n`;
}
if (sea.waves.period !== null && sea.waves.period !== undefined) {
text += ` Periodo: *${sea.waves.period}* s\n`;
}
if (sea.waves.direction !== null && sea.waves.direction !== undefined) {
text += ` Direzione: *${sea.waves.direction}*\n`;
}
}
if (sea.temperature !== null && sea.temperature !== undefined) {
text += ` Temp. acqua: *${sea.temperature}*C\n`;
}
}
return text;
}
const skFlow = require('../../config/skFlow');
const { liveMarkup } = require('../utility/live');
module.exports = {
command: 'weather',
description: 'Mostra i dati meteo attuali',
pattern: /\/weather/,
execute: async (bot, msg) => {
handler: (bot, msg) => {
const chatId = msg.chat.id;
const text = formatWeatherData();
const data = skFlow.getWithFilter('meb.forecast');
await bot.sendMessage(chatId, text, {
parse_mode: 'Markdown',
reply_markup: {
inline_keyboard: [
[{ text: 'Aggiorna', callback_data: 'weather-refresh' }]
]
let text = '';
if (!data || Object.keys(data).length === 0) {
text = 'Nessun dato meteo disponibile.';
} else {
text = '*Dati Meteo*\n\n';
for (const [path, value] of Object.entries(data)) {
const displayValue = typeof value === 'object' ? JSON.stringify(value) : value;
//TODO: ADD units
text += `*${path}*: ${displayValue}\n`;
}
}
bot.sendMessage(chatId, text, {
parse_mode: 'Markdown',
reply_to_message_id: msg.message_id,
reply_markup: liveMarkup(msg.message_id, 'weather')
});
},
formatWeatherData
}
};

121
plugin/telegram/core.js Normal file
View File

@@ -0,0 +1,121 @@
const TelegramBot = require('node-telegram-bot-api');
const fs = require('fs');
const path = require('path');
const configManager = require('../config/configManager.js');
let bot = null;
/**
* Inizializza il bot Telegram con il token dalle configurazioni del plugin.
* Carica automaticamente i comandi dalla cartella commands/ e i callback dalla cartella callbacks/.
* @returns {TelegramBot|null} L'istanza del bot o null se il token non è disponibile.
*/
function init() {
const token = configManager.getTelegramToken();
if (!token) {
console.error('[TELEGRAM] TELEGRAM_BOT_TOKEN non trovato nelle configurazioni del plugin');
return null;
}
bot = new TelegramBot(token, { polling: true });
loadCommands();
loadCallbacks();
bot.on('polling_error', (error) => {
console.error('[TELEGRAM] Errore polling:', error.message);
});
return bot;
}
/**
* Carica tutti i file dalla cartella commands/ e li registra come handler per i comandi.
* Ogni file deve esportare un oggetto con { command: string, handler: function(bot, msg, match) }
*/
function loadCommands() {
const commandsDir = path.join(__dirname, 'commands');
if (!fs.existsSync(commandsDir)) return;
const files = fs.readdirSync(commandsDir).filter(f => f.endsWith('.js'));
for (const file of files) {
try {
const cmd = require(path.join(commandsDir, file));
if (cmd.command && cmd.handler) {
bot.onText(new RegExp(`^/${cmd.command}`), (msg, match) => {
cmd.handler(bot, msg, match);
});
}
} catch (error) {
console.error(`[TELEGRAM] Errore caricamento comando ${file}:`, error.message);
}
}
}
/**
* Carica tutti i file dalla cartella callbacks/ e li registra come handler per le callback query.
* Ogni file deve esportare un oggetto con { prefix: string, handler: function(bot, query) }
*/
function loadCallbacks() {
const callbacksDir = path.join(__dirname, 'callbacks');
if (!fs.existsSync(callbacksDir)) return;
const files = fs.readdirSync(callbacksDir).filter(f => f.endsWith('.js'));
const handlers = [];
for (const file of files) {
try {
const cb = require(path.join(callbacksDir, file));
if (cb.prefix && cb.handler) {
handlers.push(cb);
}
} catch (error) {
console.error(`[TELEGRAM] Errore caricamento callback ${file}:`, error.message);
}
}
if (handlers.length > 0) {
bot.on('callback_query', (query) => {
const matched = handlers.find(h => query.data.startsWith(h.prefix));
if (matched) {
matched.handler(bot, query);
}
});
}
}
/**
* Restituisce l'istanza del bot (se inizializzato)
* @returns {TelegramBot|null}
*/
function getBot() {
return bot;
}
/**
* Invia un messaggio ad un chatId specifico
* @param {Number|String} chatId
* @param {String} text
* @param {Object} options - opzioni aggiuntive (parse_mode, reply_markup, ecc.)
*/
async function send(chatId, text, options = {}) {
if (!bot) {
console.error('[TELEGRAM] Bot non inizializzato');
return;
}
try {
await bot.sendMessage(chatId, text, options);
} catch (error) {
console.error(`[TELEGRAM] Errore invio messaggio: ${error.message}`);
}
}
module.exports = {
init,
getBot,
send
};

View File

@@ -1,269 +0,0 @@
const TelegramBot = require('node-telegram-bot-api');
const fs = require('fs');
const path = require('path');
const BOT_TOKEN = process.env.TELEGRAM_BOT_TOKEN;
let bot = null;
let app = null;
let pollingRetryCount = 0;
const MAX_POLLING_RETRIES = 10;
const POLLING_BASE_DELAY_MS = 5000;
// Registry per i comandi, callback e query inline in formato { pattern: Regex, execute: Function }
let commandsRegistry = [];
let callbackHandlers = [];
let inlineQueriesRegistry = [];
let isMessageListenerRegistered = false;
// Inizializzazione del bot.
function initBot() {
if (!BOT_TOKEN) {
console.warn("[Telegram] BOT_TOKEN not set: bot disabled");
return null;
}
if (global.__meb_telegram_bot) {
bot = global.__meb_telegram_bot;
console.log("[Telegram] Già avviato. Riavvio del bot.");
} else {
bot = new TelegramBot(BOT_TOKEN, { polling: true });
// Gestione errori di polling: intercetta EFATAL (DNS/Rete) e riavvia con backoff esponenziale
bot.on('polling_error', (error) => {
const isNetworkError = error.code === 'EFATAL' || (error.message && (error.message.includes('EAI_AGAIN') || error.message.includes('ECONNREFUSED') || error.message.includes('ETIMEDOUT')));
if (isNetworkError) {
if (pollingRetryCount >= MAX_POLLING_RETRIES) {
console.error(`[Telegram] Polling fallito dopo ${MAX_POLLING_RETRIES} tentativi. Bot disattivato. Riavviare il plugin per riprovare.`);
return;
}
pollingRetryCount++;
const delay = Math.min(POLLING_BASE_DELAY_MS * Math.pow(2, pollingRetryCount - 1), 300000); // max 5 min
console.warn(`[Telegram] Errore Polling Critico (${error.code}), tentativo ${pollingRetryCount}/${MAX_POLLING_RETRIES}. Riavvio tra ${delay / 1000}s...`);
setTimeout(() => {
bot.startPolling({ restart: true })
.then(() => { pollingRetryCount = 0; })
.catch(err => console.error("[Telegram] Errore riavvio polling:", err.message));
}, delay);
} else {
console.error(`[Telegram] Polling error: ${error.message}`);
}
});
global.__meb_telegram_bot = bot;
console.log("[Telegram] Avvio del bot.");
}
// Caricamento dei comandi e dei callback.
if (!global.__meb_telegram_handlers) {
global.__meb_telegram_handlers = true;
loadCommands();
loadCallbacks();
loadInlineQueries();
setupMessageListener(); // Registra il listener generale dei messaggi
}
return bot;
}
/**
* Registra il listener centrale per tutti i messaggi.
*/
function setupMessageListener() {
if (!bot || isMessageListenerRegistered) return;
bot.on('message', async (msg) => {
if (!msg.text) return;
// Cicla i comandi registrati e vedi se il testo corrisponde a un pattern
for (const cmd of commandsRegistry) {
if (cmd.pattern && cmd.pattern.test(msg.text)) {
try {
await cmd.execute(bot, msg, { app, getSK });
} catch (error) {
console.error(`[Telegram] Error executing command ${msg.text}:`, error);
bot.sendMessage(msg.chat.id, "⚠️ Errore interno durante l'esecuzione del comando.");
}
return; // Trovato ed eseguito
}
}
});
bot.on('callback_query', async (query) => {
const chatId = query.message.chat.id;
const data = query.data;
await bot.answerCallbackQuery(query.id);
const context = { bot, app, getSK, chatId, data, msg: query.message };
// Find matching handler
const handler = callbackHandlers.find(h => {
if (h.id) return h.id === data;
if (h.match) return h.match(data);
return false;
});
if (handler) {
try {
await handler.execute(context);
} catch (err) {
const msgErr = err.message || (err.response && err.response.body && err.response.body.description) || String(err);
if (msgErr.includes("message is not modified") || msgErr.includes("message to edit not found")) {
// Silently ignore unmodified edit or deleted message
} else {
console.error(`[Telegram] Error executing callback ${data}:`, err);
await bot.sendMessage(chatId, `Errore nella chimata dell'api, ${msgErr}.`);
}
}
} else {
console.warn(`[Telegram] Unknown callback action: ${data}`);
await bot.sendMessage(chatId, `Azione sconosciuta: ${data}`);
}
});
bot.on('inline_query', async (query) => {
const text = query.query;
// Cerca una query inline corrispondente
for (const handler of inlineQueriesRegistry) {
if (handler.pattern && handler.pattern.test(text)) {
try {
await handler.execute(bot, query, { app, getSK });
} catch (err) {
console.error(`[Telegram] Error executing inline query ${text}:`, err);
}
return;
}
}
});
isMessageListenerRegistered = true;
}
/**
* Ottiene il valore di una chiave dal DataBrowser di SignalK.
* @param {*} skPath Nome della chiave (path completo, come ad esempio "navigation.position.latitude").
* @returns Valore della chiave.
*/
function getSK(skPath) {
if (!app) return null;
const v = app.getSelfPath(skPath);
return v && v.value !== undefined && v.value !== null ? v.value : null;
}
/**
* Carica o ricarica i comandi del bot. Pulisce la cache di module_require per implementare l'hot reload.
* @returns {void}
*/
function loadCommands() {
if (!bot) return;
const commandsDir = path.join(__dirname, 'commands');
if (fs.existsSync(commandsDir)) {
commandsRegistry = []; // Svuota i vecchi comandi
const menuCommands = []; // Per il menu di Telegram
// Legge solo i file .js dalla cartella /commands.
const commandFiles = fs.readdirSync(commandsDir).filter(file => file.endsWith('.js'));
// Per ogni file, importa il comando
for (const file of commandFiles) {
const fullPath = path.resolve(commandsDir, file);
//Importa i comandi da module.exports all'interno del file
const command = require(fullPath);
//Registra il comando nel registry interno.
if (command.pattern && command.execute) {
commandsRegistry.push(command);
// Se ha una descrizione e un nome comando, lo aggiungiamo al menu
if (command.command && command.description) {
menuCommands.push({
command: command.command.toLowerCase(),
description: command.description
});
}
}
}
// Invia la lista dei comandi a Telegram per il menu a sinistra
if (menuCommands.length > 0) {
bot.setMyCommands(menuCommands).catch(err => {
console.error("[Telegram] Errore nel setMyCommands:", err);
});
}
}
}
/**
* Carica o ricarica i callback del bot.
* @returns {void}
*/
function loadCallbacks() {
if (!bot) return;
const callbacksDir = path.join(__dirname, 'callbacks');
callbackHandlers = [];
if (fs.existsSync(callbacksDir)) {
// Legge solo i file .js dalla cartella /callbacks.
const callbackFiles = fs.readdirSync(callbacksDir).filter(file => file.endsWith('.js'));
// Per ogni file, importa i callback e li aggiunge all'array callbackHandlers.
for (const file of callbackFiles) {
const fullPath = path.resolve(callbacksDir, file);
//Importa i callback da module.exports all'interno del file
const handlers = require(fullPath);
if (Array.isArray(handlers)) {
callbackHandlers.push(...handlers);
}
}
}
}
/**
* Carica o ricarica le query inline del bot.
* @returns {void}
*/
function loadInlineQueries() {
if (!bot) return;
const inlineDir = path.join(__dirname, 'inline');
inlineQueriesRegistry = [];
if (fs.existsSync(inlineDir)) {
const inlineFiles = fs.readdirSync(inlineDir).filter(file => file.endsWith('.js'));
for (const file of inlineFiles) {
const fullPath = path.resolve(inlineDir, file);
const handler = require(fullPath);
if (handler.pattern && handler.execute) {
inlineQueriesRegistry.push(handler);
}
}
}
}
/**
* Collega il bot all'app.
* @param {*} mebApp L'app di SignalK.
* @returns {TelegramBot} Il bot.
*/
function linkBotToApp(mebApp) {
app = mebApp;
bot = initBot();
return bot;
}
/**
* Invia un messaggio ad un utente tramite il bot.
* @param {*} chatId L'ID della chat.
* @param {*} text Il testo del messaggio.
* @param {*} options Le opzioni del messaggio.
* @returns {Promise<TelegramBot>} Il bot.
*/
function send(chatId, text, options = {}) {
if (!bot) return Promise.reject("Bot not initialized");
return bot.sendMessage(chatId, text, options);
}
module.exports = {
linkBotToApp,
send
};

View File

@@ -0,0 +1,17 @@
/**
* Restituisce l'oggetto reply_markup per il bottone "Chiudi", che elimina
* il messaggio del bot e il messaggio originale dell'utente.
* @param {Number} userMessageId - L'ID del messaggio originale dell'utente a cui si sta rispondendo.
* @returns {Object} Oggetto compatibile con reply_markup.
*/
function addCloseAction(userMessageId) {
return {
inline_keyboard: [
[{ text: '<- Chiudi', callback_data: `close:${userMessageId}` }]
]
};
}
module.exports = {
closeButton: addCloseAction
}

View File

@@ -0,0 +1,130 @@
const MAX_LIVE_DURATION = 2 * 60 * 1000; // 2 minuti
const UPDATE_INTERVAL = 2000; // 2 secondi
// Mappa delle sessioni live attive: chiave = `chatId:botMessageId`
const activeSessions = new Map();
/**
* Restituisce il markup con i bottoni "Live" e "Chiudi"
* @param {Number} userMessageId
* @param {String} dataType - tipo di dati (logs, marine, weather, data)
*/
function liveMarkup(userMessageId, dataType) {
return {
inline_keyboard: [
[
{ text: 'Live', callback_data: `live:${dataType}:${userMessageId}` },
{ text: '<- Chiudi', callback_data: `close:${userMessageId}` }
]
]
};
}
/**
* Restituisce il markup con il bottone "Stop"
* @param {Number} userMessageId
*/
function stopMarkup(userMessageId) {
return {
inline_keyboard: [
[{ text: 'Stop', callback_data: `livestop:${userMessageId}` }]
]
};
}
/**
* Formatta il tempo rimanente in MM:SS
*/
function formatTime(ms) {
const totalSec = Math.ceil(ms / 1000);
const min = Math.floor(totalSec / 60);
const sec = totalSec % 60;
return `${min}:${sec.toString().padStart(2, '0')}`;
}
/**
* Avvia una sessione live
* @param {Object} bot - istanza del bot
* @param {Number} chatId
* @param {Number} botMessageId - ID del messaggio del bot da aggiornare
* @param {Number} userMessageId - ID del messaggio dell'utente
* @param {Function} getTextFn - funzione che restituisce il testo aggiornato (senza timer)
*/
function startSession(bot, chatId, botMessageId, userMessageId, getTextFn) {
const key = `${chatId}:${botMessageId}`;
// Se esiste già una sessione, non avviarne una nuova
if (activeSessions.has(key)) return;
const startTime = Date.now();
// Pinna il messaggio
bot.pinChatMessage(chatId, botMessageId, { disable_notification: true }).catch(() => {});
const interval = setInterval(async () => {
const elapsed = Date.now() - startTime;
const remaining = MAX_LIVE_DURATION - elapsed;
if (remaining <= 0) {
stopSession(bot, chatId, botMessageId, userMessageId);
return;
}
try {
const freshText = getTextFn();
const textWithTimer = freshText + `\n_Live: ${formatTime(remaining)} rimanenti_`;
await bot.editMessageText(textWithTimer, {
chat_id: chatId,
message_id: botMessageId,
parse_mode: 'Markdown',
reply_markup: stopMarkup(userMessageId)
});
} catch (error) {
// Ignora errori di "message not modified"
if (!error.message?.includes('message is not modified')) {
console.error('[LIVE] Errore aggiornamento:', error.message);
}
}
}, UPDATE_INTERVAL);
activeSessions.set(key, { interval, userMessageId });
}
/**
* Ferma una sessione live, toglie il pin, elimina i messaggi
*/
async function stopSession(bot, chatId, botMessageId, userMessageId) {
const key = `${chatId}:${botMessageId}`;
const session = activeSessions.get(key);
if (session) {
clearInterval(session.interval);
activeSessions.delete(key);
}
try {
await bot.unpinChatMessage(chatId, { message_id: botMessageId }).catch(() => {});
await bot.deleteMessage(chatId, botMessageId);
if (userMessageId) {
await bot.deleteMessage(chatId, parseInt(userMessageId));
}
} catch (error) {
console.error('[LIVE] Errore chiusura sessione:', error.message);
}
}
/**
* Cerca una sessione attiva dal botMessageId
*/
function getSession(chatId, botMessageId) {
return activeSessions.get(`${chatId}:${botMessageId}`);
}
module.exports = {
liveMarkup,
stopMarkup,
startSession,
stopSession,
getSession
};