diff --git a/rules/waterButtonLearnAndCall.js b/rules/waterButtonLearnAndCall.js index fcc7e9b..227a55f 100644 --- a/rules/waterButtonLearnAndCall.js +++ b/rules/waterButtonLearnAndCall.js @@ -18,6 +18,7 @@ import fs from 'fs'; import path from 'path'; import { startStatusServer } from '../waterButtonStatusServer.js'; +import { startTelegramBot } from '../waterButtonTelegramBot.js'; import { fileURLToPath } from 'url'; const __filename = fileURLToPath(import.meta.url); @@ -29,15 +30,8 @@ const WATER_BUTTON_MAC = '08A6F773510C'; const REMOTE_SWITCH_MAC = 'CC8DA243B0A0'; // Telegram -const BOT_TOKEN = process.env.WATER_VALVE_TELEGRAM_TOKEN; -const BOT_PASSWORD = process.env.WATER_VALVE_TELEGRAM_PASSWORD; -const TELEGRAM_API = `https://api.telegram.org/bot${BOT_TOKEN}`; -const AUTH_FILE = path.join(__dirname, 'telegram_authorized_users.json'); -// Generation counter — incremented on each hot reload to stop the old poll loop -const BOT_GEN = Date.now(); -if (!global.__waterBotGen) global.__waterBotGen = 0; -global.__waterBotGen = BOT_GEN; + // sendRPC / getState persisted across hot reloads via globals (populated on first ctx event) if (!global.__waterBotRPC) global.__waterBotRPC = null; @@ -96,277 +90,15 @@ function getState(mac) { return deviceState.get(mac); } -// ── Telegram auth ───────────────────────────────────────────────────────────── -function loadAuthorizedUsers() { - try { - if (fs.existsSync(AUTH_FILE)) { - return new Set(JSON.parse(fs.readFileSync(AUTH_FILE, 'utf8'))); - } - } catch (err) { - console.error('[TelegramBot] Error loading authorized users:', err); - } - return new Set(); -} - -function saveAuthorizedUsers(users) { - try { - fs.writeFileSync(AUTH_FILE, JSON.stringify([...users], null, 2)); - } catch (err) { - console.error('[TelegramBot] Error saving authorized users:', err); - } -} - -const authorizedUsers = loadAuthorizedUsers(); - -// ── Telegram bot ────────────────────────────────────────────────────────────── - -async function telegramRequest(method, params = {}) { - const res = await fetch(`${TELEGRAM_API}/${method}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(params) - }); - return res.json(); -} - -function menuKeyboard(durSec) { - return { - keyboard: [ - [{ text: '🔓 Open valve' }, { text: '🔒 Close valve' }], - [{ text: `💧 Run timer (${durSec}s)` }, { text: '📊 Status' }] - ], - resize_keyboard: true, - persistent: true - }; -} - -async function getConnectionStatus() { - try { - const getState = global.__waterBotGetState; - if (!getState) return '❓ Button\n❓ Valve'; - const [buttonOnline, valveOnline] = await Promise.all([ - getState(WATER_BUTTON_MAC, 'system', 'online'), - getState(REMOTE_SWITCH_MAC, 'system', 'online') - ]); - const btn = buttonOnline === true ? '✅' : buttonOnline === false ? '❌ OFFLINE' : '❓'; - const vlv = valveOnline === true ? '✅' : valveOnline === false ? '❌ OFFLINE' : '❓'; - return `${btn} Button\n${vlv} Valve`; - } catch { - return '❓ Button\n❓ Valve'; - } -} - -async function reply(chatId, text, durSec) { - const connStatus = await getConnectionStatus(); - return telegramRequest('sendMessage', { - chat_id: chatId, - text: `${connStatus}\n\n${text}`, - reply_markup: menuKeyboard(durSec) - }); -} - -async function sendMessage(chatId, text) { - return telegramRequest('sendMessage', { chat_id: chatId, text }); -} - -function parseDuration(raw) { - if (!raw) return null; - if (raw.endsWith('m')) return Math.round(parseFloat(raw) * 60000); - if (raw.endsWith('s')) return Math.round(parseFloat(raw) * 1000); - const ms = parseInt(raw, 10); - return isNaN(ms) ? null : ms; -} - -async function handleBotMessage(msg) { - const chatId = msg.chat.id; - const text = (msg.text || '').trim(); - const parts = text.split(/\s+/); - // Strip @botname suffix that Telegram adds in groups - const cmd = parts[0].toLowerCase().split('@')[0]; - - // ── Authorization gate ─────────────────────────────────────────────────── - if (!authorizedUsers.has(chatId)) { - if (text === BOT_PASSWORD) { - authorizedUsers.add(chatId); - saveAuthorizedUsers(authorizedUsers); - console.log(`[TelegramBot] New authorized user: ${chatId} (${msg.from?.username || msg.from?.first_name || 'unknown'})`); - const state = getState(WATER_BUTTON_MAC); - await reply(chatId, 'Access granted. Use the buttons below or type a number (seconds) to set the duration.', (state.storedDuration / 1000).toFixed(1)); - } else { - await sendMessage(chatId, 'Please enter the password to use this bot.'); - } - return; - } - - const state = getState(WATER_BUTTON_MAC); - const durSec = (state.storedDuration / 1000).toFixed(1); - - // ── Plain number → set duration in seconds ─────────────────────────────── - const numericMatch = text.match(/^(\d+(?:\.\d+)?)$/); - if (numericMatch) { - const ms = Math.round(parseFloat(numericMatch[1]) * 1000); - if (ms <= 0 || ms > 300000) { - await reply(chatId, 'Duration must be between 1 and 300 seconds.', durSec); - return; - } - state.storedDuration = ms; - persistedState[WATER_BUTTON_MAC] = { storedDuration: ms }; - saveState(persistedState); - const newSec = (ms / 1000).toFixed(1); - await reply(chatId, `Duration set to ${newSec}s.`, newSec); - return; - } - - // ── Menu button labels map to commands ─────────────────────────────────── - const buttonMap = { - '🔓 open valve': '/open', - '🔒 close valve': '/close', - '📊 status': '/status' - }; - // Run timer button label includes dynamic duration — match by prefix - const normalised = text.toLowerCase(); - const resolvedCmd = buttonMap[normalised] - || (normalised.startsWith('💧 run timer') ? '/run' : cmd); - - if (resolvedCmd === '/start' || resolvedCmd === '/help') { - await reply(chatId, - `💧 Water valve bot\n\n` + - `Use the buttons below, or:\n` + - `/open — Open valve\n` + - `/close — Close valve\n` + - `/run — Open then close after ${durSec}s\n` + - `/duration — Set timer (e.g. 30s, 2m, 10000)\n` + - ` — Set duration in seconds (e.g. 45)\n` + - `/status — Current status`, - durSec - ); - return; - } - - if (resolvedCmd === '/status') { - await reply(chatId, - `Status:\n` + - `• Duration: ${state.storedDuration}ms (${durSec}s)\n` + - `• Count mode: ${state.countMode}\n` + - `• Timer active: ${state.timer !== null}`, - durSec - ); - return; - } - - if (resolvedCmd === '/duration') { - if (!parts[1]) { - await reply(chatId, - `Current duration: ${durSec}s\n` + - `Type a number (seconds) or use /duration \n` + - `Examples: 30 /duration 30s /duration 2m`, - durSec - ); - return; - } - const ms = parseDuration(parts[1]); - if (!ms || ms <= 0 || ms > 300000) { - await reply(chatId, 'Invalid duration. Use e.g. 30s, 2m, or seconds 1–300.', durSec); - return; - } - state.storedDuration = ms; - persistedState[WATER_BUTTON_MAC] = { storedDuration: ms }; - saveState(persistedState); - const newSec = (ms / 1000).toFixed(1); - await reply(chatId, `Duration set to ${newSec}s.`, newSec); - return; - } - - const sendRPC = global.__waterBotRPC; - if (!sendRPC) { - await reply(chatId, 'Not ready yet — waiting for first device event. Try again in a moment.', durSec); - return; - } - - if (resolvedCmd === '/open') { - if (state.timer) { clearTimeout(state.timer); state.timer = null; } - state.countMode = false; - state.timerStartedAt = null; - state.timerEndsAt = null; - await setLight(sendRPC, WATER_BUTTON_MAC, false, 0); // light off = switch on = valve open - await reply(chatId, 'Valve opened.', durSec); - - } else if (resolvedCmd === '/close') { - if (state.timer) { clearTimeout(state.timer); state.timer = null; } - state.countMode = false; - state.timerStartedAt = null; - state.timerEndsAt = null; - await setLight(sendRPC, WATER_BUTTON_MAC, true, 95); // light on = switch off = valve closed - await reply(chatId, 'Valve closed.', durSec); - - } else if (resolvedCmd === '/run') { - if (state.timer) { clearTimeout(state.timer); state.timer = null; } - state.countMode = false; - await setLight(sendRPC, WATER_BUTTON_MAC, false, 0); // open valve - const duration = state.storedDuration; - state.timerStartedAt = Date.now(); - state.timerEndsAt = Date.now() + duration; - await reply(chatId, `Valve opened. Closing in ${(duration / 1000).toFixed(1)}s…`, durSec); - - state.timer = setTimeout(async () => { - try { - const rpc = global.__waterBotRPC; - if (rpc) await setLight(rpc, WATER_BUTTON_MAC, true, 95); // close valve - state.timer = null; - state.timerStartedAt = null; - state.timerEndsAt = null; - await reply(chatId, 'Timer elapsed — valve closed.', (getState(WATER_BUTTON_MAC).storedDuration / 1000).toFixed(1)); - } catch (err) { - console.error('[TelegramBot] Timer callback error:', err); - } - }, duration); - - } else { - await reply(chatId, 'Unknown command. Use the buttons below or send /help.', durSec); - } -} - -async function botLoop() { - console.log(`[TelegramBot] Starting (gen ${BOT_GEN})`); - let offset = 0; - - while (BOT_GEN === global.__waterBotGen) { - try { - const data = await telegramRequest('getUpdates', { - offset, - timeout: 25, - allowed_updates: ['message'] - }); - - if (!data.ok) { - await sleep(5000); - continue; - } - - for (const update of data.result || []) { - offset = update.update_id + 1; - if (update.message) { - handleBotMessage(update.message).catch(err => - console.error('[TelegramBot] Handler error:', err) - ); - } - } - } catch (err) { - console.error('[TelegramBot] Polling error:', err.message); - await sleep(5000); - } - } - - console.log(`[TelegramBot] Stopped (gen ${BOT_GEN} superseded)`); -} - -// Start long-polling immediately (non-blocking) -if (BOT_TOKEN) { - botLoop().catch(err => console.error('[TelegramBot] Fatal error:', err)); -} else { - console.error('[TelegramBot] WATER_VALVE_TELEGRAM_TOKEN not set in .env — bot disabled'); -} +startTelegramBot({ + getState, + WATER_BUTTON_MAC, + REMOTE_SWITCH_MAC, + setLight, + persistedState, + saveState +}); // ── Rule export ─────────────────────────────────────────────────────────────── diff --git a/waterButtonTelegramBot.js b/waterButtonTelegramBot.js new file mode 100644 index 0000000..d436bc0 --- /dev/null +++ b/waterButtonTelegramBot.js @@ -0,0 +1,301 @@ +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const AUTH_FILE = path.join(__dirname, 'telegram_authorized_users.json'); +const BOT_TOKEN = process.env.WATER_VALVE_TELEGRAM_TOKEN; +const BOT_PASSWORD = process.env.WATER_VALVE_TELEGRAM_PASSWORD; +const TELEGRAM_API = `https://api.telegram.org/bot${BOT_TOKEN}`; +const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms)); + + +// Generation counter — incremented on each hot reload to stop the old poll loop +const BOT_GEN = Date.now(); +if (!global.__waterBotGen) global.__waterBotGen = 0; +global.__waterBotGen = BOT_GEN; + +export function startTelegramBot({ + getState, + WATER_BUTTON_MAC, + REMOTE_SWITCH_MAC, + setLight, + persistedState, + saveState +}) { + // ── Telegram auth ───────────────────────────────────────────────────────────── + + function loadAuthorizedUsers() { + try { + if (fs.existsSync(AUTH_FILE)) { + return new Set(JSON.parse(fs.readFileSync(AUTH_FILE, 'utf8'))); + } + } catch (err) { + console.error('[TelegramBot] Error loading authorized users:', err); + } + return new Set(); + } + + function saveAuthorizedUsers(users) { + try { + fs.writeFileSync(AUTH_FILE, JSON.stringify([...users], null, 2)); + } catch (err) { + console.error('[TelegramBot] Error saving authorized users:', err); + } + } + + const authorizedUsers = loadAuthorizedUsers(); + + // ── Telegram bot ────────────────────────────────────────────────────────────── + + async function telegramRequest(method, params = {}) { + const res = await fetch(`${TELEGRAM_API}/${method}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(params) + }); + return res.json(); + } + + function menuKeyboard(durSec) { + return { + keyboard: [ + [{ text: '🔓 Open valve' }, { text: '🔒 Close valve' }], + [{ text: `💧 Run timer (${durSec}s)` }, { text: '📊 Status' }] + ], + resize_keyboard: true, + persistent: true + }; + } + + async function getConnectionStatus() { + try { + const getState = global.__waterBotGetState; + if (!getState) return '❓ Button\n❓ Valve'; + const [buttonOnline, valveOnline] = await Promise.all([ + getState(WATER_BUTTON_MAC, 'system', 'online'), + getState(REMOTE_SWITCH_MAC, 'system', 'online') + ]); + const btn = buttonOnline === true ? '✅' : buttonOnline === false ? '❌ OFFLINE' : '❓'; + const vlv = valveOnline === true ? '✅' : valveOnline === false ? '❌ OFFLINE' : '❓'; + return `${btn} Button\n${vlv} Valve`; + } catch { + return '❓ Button\n❓ Valve'; + } + } + + async function reply(chatId, text, durSec) { + const connStatus = await getConnectionStatus(); + return telegramRequest('sendMessage', { + chat_id: chatId, + text: `${connStatus}\n\n${text}`, + reply_markup: menuKeyboard(durSec) + }); + } + + async function sendMessage(chatId, text) { + return telegramRequest('sendMessage', { chat_id: chatId, text }); + } + + function parseDuration(raw) { + if (!raw) return null; + if (raw.endsWith('m')) return Math.round(parseFloat(raw) * 60000); + if (raw.endsWith('s')) return Math.round(parseFloat(raw) * 1000); + const ms = parseInt(raw, 10); + return isNaN(ms) ? null : ms; + } + + async function handleBotMessage(msg) { + const chatId = msg.chat.id; + const text = (msg.text || '').trim(); + const parts = text.split(/\s+/); + // Strip @botname suffix that Telegram adds in groups + const cmd = parts[0].toLowerCase().split('@')[0]; + + // ── Authorization gate ─────────────────────────────────────────────────── + if (!authorizedUsers.has(chatId)) { + if (text === BOT_PASSWORD) { + authorizedUsers.add(chatId); + saveAuthorizedUsers(authorizedUsers); + console.log(`[TelegramBot] New authorized user: ${chatId} (${msg.from?.username || msg.from?.first_name || 'unknown'})`); + const state = getState(WATER_BUTTON_MAC); + await reply(chatId, 'Access granted. Use the buttons below or type a number (seconds) to set the duration.', (state.storedDuration / 1000).toFixed(1)); + } else { + await sendMessage(chatId, 'Please enter the password to use this bot.'); + } + return; + } + + const state = getState(WATER_BUTTON_MAC); + const durSec = (state.storedDuration / 1000).toFixed(1); + + // ── Plain number → set duration in seconds ─────────────────────────────── + const numericMatch = text.match(/^(\d+(?:\.\d+)?)$/); + if (numericMatch) { + const ms = Math.round(parseFloat(numericMatch[1]) * 1000); + if (ms <= 0 || ms > 300000) { + await reply(chatId, 'Duration must be between 1 and 300 seconds.', durSec); + return; + } + state.storedDuration = ms; + persistedState[WATER_BUTTON_MAC] = { storedDuration: ms }; + saveState(persistedState); + const newSec = (ms / 1000).toFixed(1); + await reply(chatId, `Duration set to ${newSec}s.`, newSec); + return; + } + + // ── Menu button labels map to commands ─────────────────────────────────── + const buttonMap = { + '🔓 open valve': '/open', + '🔒 close valve': '/close', + '📊 status': '/status' + }; + // Run timer button label includes dynamic duration — match by prefix + const normalised = text.toLowerCase(); + const resolvedCmd = buttonMap[normalised] + || (normalised.startsWith('💧 run timer') ? '/run' : cmd); + + if (resolvedCmd === '/start' || resolvedCmd === '/help') { + await reply(chatId, + `💧 Water valve bot\n\n` + + `Use the buttons below, or:\n` + + `/open — Open valve\n` + + `/close — Close valve\n` + + `/run — Open then close after ${durSec}s\n` + + `/duration — Set timer (e.g. 30s, 2m, 10000)\n` + + ` — Set duration in seconds (e.g. 45)\n` + + `/status — Current status`, + durSec + ); + return; + } + + if (resolvedCmd === '/status') { + await reply(chatId, + `Status:\n` + + `• Duration: ${state.storedDuration}ms (${durSec}s)\n` + + `• Count mode: ${state.countMode}\n` + + `• Timer active: ${state.timer !== null}`, + durSec + ); + return; + } + + if (resolvedCmd === '/duration') { + if (!parts[1]) { + await reply(chatId, + `Current duration: ${durSec}s\n` + + `Type a number (seconds) or use /duration \n` + + `Examples: 30 /duration 30s /duration 2m`, + durSec + ); + return; + } + const ms = parseDuration(parts[1]); + if (!ms || ms <= 0 || ms > 300000) { + await reply(chatId, 'Invalid duration. Use e.g. 30s, 2m, or seconds 1–300.', durSec); + return; + } + state.storedDuration = ms; + persistedState[WATER_BUTTON_MAC] = { storedDuration: ms }; + saveState(persistedState); + const newSec = (ms / 1000).toFixed(1); + await reply(chatId, `Duration set to ${newSec}s.`, newSec); + return; + } + + const sendRPC = global.__waterBotRPC; + if (!sendRPC) { + await reply(chatId, 'Not ready yet — waiting for first device event. Try again in a moment.', durSec); + return; + } + + if (resolvedCmd === '/open') { + if (state.timer) { clearTimeout(state.timer); state.timer = null; } + state.countMode = false; + state.timerStartedAt = null; + state.timerEndsAt = null; + await setLight(sendRPC, WATER_BUTTON_MAC, false, 0); // light off = switch on = valve open + await reply(chatId, 'Valve opened.', durSec); + + } else if (resolvedCmd === '/close') { + if (state.timer) { clearTimeout(state.timer); state.timer = null; } + state.countMode = false; + state.timerStartedAt = null; + state.timerEndsAt = null; + await setLight(sendRPC, WATER_BUTTON_MAC, true, 95); // light on = switch off = valve closed + await reply(chatId, 'Valve closed.', durSec); + + } else if (resolvedCmd === '/run') { + if (state.timer) { clearTimeout(state.timer); state.timer = null; } + state.countMode = false; + await setLight(sendRPC, WATER_BUTTON_MAC, false, 0); // open valve + const duration = state.storedDuration; + state.timerStartedAt = Date.now(); + state.timerEndsAt = Date.now() + duration; + await reply(chatId, `Valve opened. Closing in ${(duration / 1000).toFixed(1)}s…`, durSec); + + state.timer = setTimeout(async () => { + try { + const rpc = global.__waterBotRPC; + if (rpc) await setLight(rpc, WATER_BUTTON_MAC, true, 95); // close valve + state.timer = null; + state.timerStartedAt = null; + state.timerEndsAt = null; + await reply(chatId, 'Timer elapsed — valve closed.', (getState(WATER_BUTTON_MAC).storedDuration / 1000).toFixed(1)); + } catch (err) { + console.error('[TelegramBot] Timer callback error:', err); + } + }, duration); + + } else { + await reply(chatId, 'Unknown command. Use the buttons below or send /help.', durSec); + } + } + + async function botLoop() { + console.log(`[TelegramBot] Starting (gen ${BOT_GEN})`); + let offset = 0; + + while (BOT_GEN === global.__waterBotGen) { + try { + const data = await telegramRequest('getUpdates', { + offset, + timeout: 25, + allowed_updates: ['message'] + }); + + if (!data.ok) { + await sleep(5000); + continue; + } + + for (const update of data.result || []) { + offset = update.update_id + 1; + if (update.message) { + handleBotMessage(update.message).catch(err => + console.error('[TelegramBot] Handler error:', err) + ); + } + } + } catch (err) { + console.error('[TelegramBot] Polling error:', err.message); + await sleep(5000); + } + } + + console.log(`[TelegramBot] Stopped (gen ${BOT_GEN} superseded)`); + } + + // Start long-polling immediately (non-blocking) + if (BOT_TOKEN) { + botLoop().catch(err => console.error('[TelegramBot] Fatal error:', err)); + } else { + console.error('[TelegramBot] WATER_VALVE_TELEGRAM_TOKEN not set in .env — bot disabled'); + } + + +}