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'); } }