⚡ madLENotify: уведомления об отключениях Ленэнерго в Telegram
madLENotify — маленький самописный демон на C++17, который периодически проверяет страницу плановых работ 🌐 и отправляет уведомления в Telegram 📨 при появлении новых отключений в выбранном населённом пункте.
✅ Что умеет:
• 📅 парсит плановые отключения (дата/время/адрес/комментарий)
• 🔔 фильтрует по окну уведомлений (например, за 3 дня вперёд)
• 🕘 отправляет сообщения после заданного времени (send_time)
• 🧠 не спамит: ведёт журнал отправок (sent_log.json)
• 🌐 имеет простой HTTP API для просмотра/обновления конфига (по желанию)
• 🧾 умеет прислать историю прошедших отключений (history)
🧩 Как это работает
- Демон раз в
check_interval_secсекунд качает страницу planned_work. - Парсит HTML-таблицу и выделяет события отключений.
- Фильтрует события по посёлку/городу (settlement) и по диапазону дней (alert_days).
- Если наступило время отправки (
send_time), то формирует сообщение по шаблону и шлёт в Telegram. - Факт отправки фиксируется в
sent_log.json, чтобы не дублить одно и то же.
🛡️ Про лимит daily_limit
Параметр daily_limit ограничивает количество сообщений в сутки на каждое отдельное событие, а не “одно на всё”. То есть если в один день появилось два разных отключения — можно получить два уведомления даже при daily_limit = 1. Это важно, когда Ленэнерго публикует несколько объявлений на разные интервалы времени.
📦 Установка и сборка
🧰 Зависимости
- 🐧 Linux сервер (обычно Debian/Ubuntu).
- 🧱 Компилятор
g++с поддержкой C++17. - 🌐 libcurl (
-lcurl). - 🧵 pthread (
-lpthread). - 📄
json.hpp(nlohmann/json) иhttplib.h(если используешь HTTP API).
🔨 Сборка
Пример команды сборки:
g++ -o lenotify lenotify.cpp -std=c++17 -O2 -lcurl -lpthread -I.После сборки рядом появится бинарник lenotify. Его можно запускать вручную, либо оформить как systemd-сервис.
⚙️ Настройка: settings.json
Конфигурация хранится в JSON. Основные поля:
- 📍
settlement— населённый пункт для фильтра (например, “Пески”). - 🔗
url_base— базовый URL planned_work. - 🔔
alert_days— за сколько дней вперёд предупреждать. - 🕘
send_time— время (МСК), после которого можно отправлять уведомления. - ⏱
check_interval_sec— период проверки (в секундах). - 🚦
daily_limit— лимит сообщений в сутки по каждому событию. - 💬
default_message— шаблон сообщения с плейсхолдерами. - 📨 Telegram:
telegram_bot_token,telegram_api_url,chat_ids. - 🌐
api_port— порт локального HTTP API (если нужно).
🧾 Листинг конфигурации:
{ "settlement": "Пески", "url_base": "https://rosseti-lenenergo.ru/planned_work/", "check_interval_sec": 1800, "daily_limit": 5, "alert_days": 3, "send_time": "09:00", "test_mode": false, "max_message_len": 320, "default_message": "⚡ Плановое отключение электроэнергии: {badge} {when} {time_range} — {address_short}{comment_opt}" ,м "tg_bot_token": "8356004729:ABHt4Rw8XTujAl_QQLyTbsddCQEcRrbXMD8", "tg_chat_id": "-1003310217653", "api_port": 8080 }
🧠 Код демона: lenotify.cpp
Внутри программы есть несколько важных частей:
- 🌐 загрузка HTML через libcurl;
- 🧽 очистка HTML и парсинг строк таблицы отключений;
- 🧾 формирование хеша события (дата/время/адрес/комментарий) — чтобы отличать события друг от друга;
- 📓
sent_log.json— учёт отправленных сообщений, чтобы не повторяться; - 📨 отправка в Telegram через
sendMessage; - 🌐 небольшой HTTP API для просмотра/обновления конфига (опционально).
🧾 Основной исходник:
// lenotify.cpp // ----------------------------------------------------------------------------- // madLENotify v1.5 - Уведомления об отключениях Ленэнерго через Telegram // // Build: // g++ -o lenotify lenotify.cpp -std=c++17 -O2 -lcurl -lpthread -I. // // Run: // ./lenotify // ./lenotify --once // ./lenotify --test --force --once // ./lenotify --test-send // ./lenotify --history // ./lenotify --tg-check // ./lenotify --config /root/settings.json --history // ./lenotify --man // // Notes: // - Источник дат по МСК. Чтобы days_left совпадал, можно запускать так: // TZ=Europe/Moscow ./lenotify --once // ----------------------------------------------------------------------------- #include <curl/curl.h> #include <algorithm> #include <chrono> #include <cctype> #include <ctime> #include <fstream> #include <iomanip> #include <iostream> #include <mutex> #include <regex> #include <sstream> #include <string> #include <thread> #include <vector> #include "json.hpp" #include "httplib.h" using json = nlohmann::json; // ------------------------- Logging ------------------------- static void log_line(const std::string& msg) { std::ofstream logfile("log.txt", std::ios::app); auto now = std::chrono::system_clock::now(); std::time_t t = std::chrono::system_clock::to_time_t(now); logfile << std::put_time(std::localtime(&t), "%Y-%m-%d %H:%M:%S") << " " << msg << "\n"; std::cout << std::put_time(std::localtime(&t), "%Y-%m-%d %H:%M:%S") << " " << msg << "\n"; } // ------------------------- CLI man/help ------------------------- static const char* MAN_TEXT = R"MAN( madLENotify v1.5 — оповещения об отключениях (Ленэнерго planned_work) через Telegram бота Использование: ./lenotify [--config FILE] [--once] [--test] [--force] [--test-send] [--debug] [--history] [--tg-check] [--man|--help] Опции: --config FILE Явно указать путь к файлу настроек (JSON). Если не задано — пробует settings.json затем config.json в текущей директории. --man, --help Показать справку. --once Один цикл проверки и выход. --test Отладка: слать сразу (игнор send_time и daily_limit). --force Принудительно слать даже если уже слали сегодня (игнор sent_log). --test-send Отправить тестовое сообщение и выйти. --debug Подробный вывод отладочной информации. --history Отправить в Telegram таблицу истории (прошедшие отключения). --tg-check Проверить Telegram токен через getMe и выйти. Поддерживаемые ключи в JSON (оба варианта): Новый/унифицированный формат: telegram_bot_token, telegram_api_url, chat_ids (array of strings) Старый/короткий формат: tg_bot_token, tg_api_url, tg_chat_id (string) Остальное: settlement, url_base, alert_days, send_time, check_interval_sec, daily_limit, max_message_len, default_message, api_port, history_days, history_max_rows, debug_telegram_url Важно: Токен бота нельзя публиковать. Если засветили — перевыпустите в @BotFather. )MAN"; // ------------------------- Helpers: strings ------------------------- static std::string trim_ws(std::string s) { auto issp = [](unsigned char c){ return std::isspace(c) != 0; }; while (!s.empty() && issp((unsigned char)s.front())) s.erase(s.begin()); while (!s.empty() && issp((unsigned char)s.back())) s.pop_back(); return s; } static std::string collapse_spaces(std::string s) { s = std::regex_replace(s, std::regex("\\s+"), " "); return trim_ws(s); } static std::string replace_all(std::string s, const std::string& what, const std::string& with) { if (what.empty()) return s; size_t pos = 0; while ((pos = s.find(what, pos)) != std::string::npos) { s.replace(pos, what.size(), with); pos += with.size(); } return s; } static std::string strip_tags(std::string s) { s = std::regex_replace(s, std::regex("<[^>]*>"), " "); s = std::regex_replace(s, std::regex(" | "), " "); s = std::regex_replace(s, std::regex("&"), "&"); s = std::regex_replace(s, std::regex("""), "\""); s = std::regex_replace(s, std::regex("<"), "<"); s = std::regex_replace(s, std::regex(">"), ">"); return collapse_spaces(s); } static std::string remove_uuids(std::string s) { s = std::regex_replace( s, std::regex(R"(\b[0-9a-fA-F]{8}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{12}\b)"), "" ); return collapse_spaces(s); } static std::string html_escape(std::string s) { s = replace_all(s, "&", "&"); s = replace_all(s, "<", "<"); s = replace_all(s, ">", ">"); return s; } // ------------------------- UTF-8 validation + cp1251->utf8 fallback ------------------------- static bool is_valid_utf8(const std::string& s) { const unsigned char* p = (const unsigned char*)s.data(); size_t n = s.size(); size_t i = 0; while (i < n) { unsigned char c = p[i]; if (c <= 0x7F) { i++; continue; } if ((c & 0xE0) == 0xC0) { if (i + 1 >= n) return false; unsigned char c1 = p[i + 1]; if ((c1 & 0xC0) != 0x80) return false; if (c == 0xC0 || c == 0xC1) return false; i += 2; continue; } if ((c & 0xF0) == 0xE0) { if (i + 2 >= n) return false; unsigned char c1 = p[i + 1], c2 = p[i + 2]; if ((c1 & 0xC0) != 0x80 || (c2 & 0xC0) != 0x80) return false; if (c == 0xE0 && (c1 & 0xE0) == 0x80) return false; if (c == 0xED && (c1 & 0xE0) == 0xA0) return false; i += 3; continue; } if ((c & 0xF8) == 0xF0) { if (i + 3 >= n) return false; unsigned char c1 = p[i + 1], c2 = p[i + 2], c3 = p[i + 3]; if ((c1 & 0xC0) != 0x80 || (c2 & 0xC0) != 0x80 || (c3 & 0xC0) != 0x80) return false; if (c == 0xF0 && (c1 & 0xF0) == 0x80) return false; if (c > 0xF4) return false; if (c == 0xF4 && c1 > 0x8F) return false; i += 4; continue; } return false; } return true; } static std::string cp1251_to_utf8(const std::string& in) { std::string out; out.reserve(in.size() * 2); auto append_utf8 = [&](uint32_t cp) { if (cp <= 0x7F) out.push_back((char)cp); else if (cp <= 0x7FF) { out.push_back((char)(0xC0 | (cp >> 6))); out.push_back((char)(0x80 | (cp & 0x3F))); } else { out.push_back((char)(0xE0 | (cp >> 12))); out.push_back((char)(0x80 | ((cp >> 6) & 0x3F))); out.push_back((char)(0x80 | (cp & 0x3F))); } }; for (unsigned char ch : in) { if (ch < 0x80) { out.push_back((char)ch); continue; } uint32_t cp = 0; if (ch == 0xA8) cp = 0x0401; else if (ch == 0xB8) cp = 0x0451; else if (ch >= 0xC0 && ch <= 0xDF) cp = 0x0410 + (ch - 0xC0); else if (ch >= 0xE0 && ch <= 0xFF) cp = 0x0430 + (ch - 0xE0); else { switch (ch) { case 0x82: cp = 0x201A; break; case 0x84: cp = 0x201E; break; case 0x85: cp = 0x2026; break; case 0x86: cp = 0x2020; break; case 0x87: cp = 0x2021; break; case 0x91: cp = 0x2018; break; case 0x92: cp = 0x2019; break; case 0x93: cp = 0x201C; break; case 0x94: cp = 0x201D; break; case 0x96: cp = 0x2013; break; case 0x97: cp = 0x2014; break; default: cp = 0x003F; break; } } append_utf8(cp); } return out; } static bool meta_says_cp1251(const std::string& html) { std::string h = html; std::transform(h.begin(), h.end(), h.begin(), [](unsigned char c){ return (char)std::tolower(c); }); return (h.find("charset=windows-1251") != std::string::npos) || (h.find("charset=cp1251") != std::string::npos) || (h.find("charset=1251") != std::string::npos); } static std::string ensure_utf8_for_telegram(const std::string& s) { if (is_valid_utf8(s)) return s; std::string converted = cp1251_to_utf8(s); if (is_valid_utf8(converted)) return converted; std::string out; out.reserve(s.size()); for (unsigned char c : s) out.push_back((c < 0x80) ? (char)c : '?'); return out; } // ------------------------- CURL helpers ------------------------- static size_t write_callback(void* contents, size_t size, size_t nmemb, std::string* s) { s->append(static_cast<char*>(contents), size * nmemb); return size * nmemb; } static std::string curl_escape_value(const std::string& value) { CURL* curl = curl_easy_init(); if (!curl) return value; char* encoded = curl_easy_escape(curl, value.c_str(), 0); std::string result = encoded ? encoded : ""; if (encoded) curl_free(encoded); curl_easy_cleanup(curl); return result; } static bool curl_http_get(const std::string& url, std::string& out_body, std::string& out_err, long timeout_sec=15) { out_body.clear(); out_err.clear(); CURL* curl = curl_easy_init(); if (!curl) { out_err = "Failed to initialize CURL"; return false; } curl_easy_setopt(curl, CURLOPT_URL, url.c_str()); curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_callback); curl_easy_setopt(curl, CURLOPT_WRITEDATA, &out_body); curl_easy_setopt(curl, CURLOPT_TIMEOUT, timeout_sec); curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L); curl_easy_setopt(curl, CURLOPT_USERAGENT, "madLENotify/1.5 (curl)"); CURLcode res = curl_easy_perform(curl); if (res != CURLE_OK) { out_err = std::string("CURL error: ") + curl_easy_strerror(res); curl_easy_cleanup(curl); return false; } curl_easy_cleanup(curl); return true; } static bool curl_http_post_form(const std::string& url, const std::string& post_data, std::string& out_body, std::string& out_err, long timeout_sec=15) { out_body.clear(); out_err.clear(); CURL* curl = curl_easy_init(); if (!curl) { out_err = "Failed to initialize CURL"; return false; } struct curl_slist* headers = nullptr; headers = curl_slist_append(headers, "Content-Type: application/x-www-form-urlencoded"); curl_easy_setopt(curl, CURLOPT_URL, url.c_str()); curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); curl_easy_setopt(curl, CURLOPT_POST, 1L); curl_easy_setopt(curl, CURLOPT_POSTFIELDS, post_data.c_str()); curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_callback); curl_easy_setopt(curl, CURLOPT_WRITEDATA, &out_body); curl_easy_setopt(curl, CURLOPT_TIMEOUT, timeout_sec); curl_easy_setopt(curl, CURLOPT_USERAGENT, "madLENotify/1.5 (curl)"); CURLcode res = curl_easy_perform(curl); curl_slist_free_all(headers); curl_easy_cleanup(curl); if (res != CURLE_OK) { out_err = std::string("CURL error: ") + curl_easy_strerror(res); return false; } return true; } // ------------------------- Fetch planned_work HTML ------------------------- static std::string fetch_html(const std::string& url) { std::string response, err; bool ok = curl_http_get(url, response, err, 30); if (!ok) { log_line("Fetch failed: " + err); return ""; } return response; } // ------------------------- Time/date helpers ------------------------- static std::string today_ymd() { time_t now = time(nullptr); tm* lt = localtime(&now); std::ostringstream ss; ss << (lt->tm_year + 1900) << "-" << std::setw(2) << std::setfill('0') << (lt->tm_mon + 1) << "-" << std::setw(2) << std::setfill('0') << lt->tm_mday; return ss.str(); } static void load_daily_sent(const std::string& file, std::string& day, int& sent) { day.clear(); sent = 0; std::ifstream f(file); if (!f) return; f >> day >> sent; if (!f) { day.clear(); sent = 0; } } static void save_daily_sent(const std::string& file, const std::string& day, int sent) { std::ofstream f(file, std::ios::trunc); f << day << " " << sent; } static bool parse_hhmm(const std::string& s, int& hh, int& mm) { hh = mm = 0; if (sscanf(s.c_str(), "%d:%d", &hh, &mm) != 2) return false; if (hh < 0 || hh > 23 || mm < 0 || mm > 59) return false; return true; } static int minutes_now_local() { time_t now = time(nullptr); tm* lt = localtime(&now); return lt->tm_hour * 60 + lt->tm_min; } static std::string dmy_dash_to_dot(std::string s) { for (char& c : s) if (c == '-') c = '.'; return s; } static std::string date_dot_to_ddmm(const std::string& d) { int day=0, mon=0, year=0; if (sscanf(d.c_str(), "%d.%d.%d", &day, &mon, &year) != 3) return ""; std::ostringstream ss; ss << std::setw(2) << std::setfill('0') << day << "." << std::setw(2) << std::setfill('0') << mon; return ss.str(); } static bool parse_dt_dot(const std::string& date_dot, const std::string& time_hm, std::tm& out) { int d=0,m=0,y=0, hh=0, mm=0; if (sscanf(date_dot.c_str(), "%d.%d.%d", &d, &m, &y) != 3) return false; if (!time_hm.empty()) { if (sscanf(time_hm.c_str(), "%d:%d", &hh, &mm) != 2) return false; } std::tm t{}; t.tm_mday = d; t.tm_mon = m - 1; t.tm_year = y - 1900; t.tm_hour = hh; t.tm_min = mm; t.tm_sec = 0; t.tm_isdst = -1; out = t; return true; } static std::tm today_date_tm() { time_t now = time(nullptr); tm t = *localtime(&now); t.tm_hour = t.tm_min = t.tm_sec = 0; t.tm_isdst = -1; return t; } static int days_between_dates_local(const std::tm& a, const std::tm& b) { std::tm aa = a; std::tm bb = b; aa.tm_hour = aa.tm_min = aa.tm_sec = 0; bb.tm_hour = bb.tm_min = bb.tm_sec = 0; time_t ta = mktime(&aa); time_t tb = mktime(&bb); double diff = difftime(tb, ta); return (int)(diff / 86400.0); } // ------------------------- Config: defaults + compatibility ------------------------- static void ensure_config_defaults(json& cfg) { if (!cfg.contains("settlement")) cfg["settlement"] = "Пески"; if (!cfg.contains("url_base")) cfg["url_base"] = "https://rosseti-lenenergo.ru/planned_work/"; if (!cfg.contains("daily_limit")) cfg["daily_limit"] = 5; if (!cfg.contains("check_interval_sec")) cfg["check_interval_sec"] = 1800; if (!cfg.contains("alert_days")) cfg["alert_days"] = 3; if (!cfg.contains("send_time")) cfg["send_time"] = "09:00"; if (!cfg.contains("test_mode")) cfg["test_mode"] = false; if (!cfg.contains("max_message_len")) cfg["max_message_len"] = 4096; if (!cfg.contains("default_message")) cfg["default_message"] = "⚡ {badge} {when} {time_range} — {address_short}{comment_opt}"; if (!cfg.contains("api_port")) cfg["api_port"] = 8080; if (!cfg.contains("history_days")) cfg["history_days"] = 30; if (!cfg.contains("history_max_rows")) cfg["history_max_rows"] = 200; if (!cfg.contains("debug_telegram_url")) cfg["debug_telegram_url"] = true; // Telegram API base if (!cfg.contains("telegram_api_url") && cfg.contains("tg_api_url")) cfg["telegram_api_url"] = cfg["tg_api_url"]; if (!cfg.contains("telegram_api_url")) cfg["telegram_api_url"] = "https://api.telegram.org"; // Token compatibility if (!cfg.contains("telegram_bot_token") && cfg.contains("tg_bot_token")) cfg["telegram_bot_token"] = cfg["tg_bot_token"]; if (!cfg.contains("telegram_bot_token")) cfg["telegram_bot_token"] = ""; // Chat ids compatibility if (!cfg.contains("chat_ids")) { if (cfg.contains("tg_chat_id")) { cfg["chat_ids"] = json::array({ cfg["tg_chat_id"] }); } else { cfg["chat_ids"] = json::array(); } } else { // ensure array if (!cfg["chat_ids"].is_array()) { json arr = json::array(); arr.push_back(cfg["chat_ids"]); cfg["chat_ids"] = arr; } } } static bool file_exists(const std::string& path) { std::ifstream f(path); return f.good(); } static bool load_config_from_file(const std::string& path, json& out_cfg, std::string& out_err) { out_err.clear(); std::ifstream f(path); if (!f.is_open()) { out_err = "Cannot open file: " + path; return false; } try { f >> out_cfg; } catch (const std::exception& e) { out_err = std::string("JSON parse error: ") + e.what(); return false; } ensure_config_defaults(out_cfg); return true; } // ------------------------- URL planned_work builder ------------------------- static std::string build_planned_work_url(const json& cfg) { std::string base = cfg.value("url_base", "https://rosseti-lenenergo.ru/planned_work/"); if (base.find('?') == std::string::npos) { base += "?reg=&date_start=&date_finish=&res=&"; } else { if (base.back() != '&' && base.back() != '?') base += "&"; } const std::string settlement = cfg.value("settlement", "Пески"); const std::string city = curl_escape_value(settlement); if (base.find("city=") == std::string::npos) base += "city=" + city + "&"; if (base.find("street=") == std::string::npos) base += "street="; return base; } // ------------------------- Outage + hashing ------------------------- struct Outage { std::string date_start_dot; // dd.mm.yyyy std::string time_start; // HH:MM std::string date_end_dot; std::string time_end; std::string address; std::string comment; std::tm start_tm{}; bool start_tm_ok = false; std::string key_string() const { return date_start_dot + "|" + time_start + "|" + date_end_dot + "|" + time_end + "|" + address + "|" + comment; } std::string hash64_hex() const { const uint64_t FNV_OFFSET = 1469598103934665603ull; const uint64_t FNV_PRIME = 1099511628211ull; uint64_t h = FNV_OFFSET; std::string k = key_string(); for (unsigned char c : k) { h ^= (uint64_t)c; h *= FNV_PRIME; } std::ostringstream ss; ss << std::hex << std::setfill('0') << std::setw(16) << h; return ss.str(); } }; // ------------------------- Parser helpers ------------------------- static bool contains_settlement(const std::string& row_text, const std::string& settlement) { if (settlement.empty() || row_text.empty()) return false; std::string lower_row = row_text; std::transform(lower_row.begin(), lower_row.end(), lower_row.begin(), [](unsigned char c){ return (char)std::tolower(c); }); std::string lower_settlement = settlement; std::transform(lower_settlement.begin(), lower_settlement.end(), lower_settlement.begin(), [](unsigned char c){ return (char)std::tolower(c); }); if (lower_row.find(lower_settlement) != std::string::npos) return true; std::vector<std::string> prefixes = { "д ", "дер ", "деревня ", "пос ", "пос. ", "посёлок ", "п ", "п. ", "с ", "село ", "г ", "город ", "рп ", "рабочий поселок ", "тер ", "тер. ", "территория ", "мкр ", "микрорайон ", "ул ", "улица ", "пер ", "переулок ", "пр ", "проспект ", "б-р ", "бульвар ", "ш ", "шоссе " }; for (const auto& prefix : prefixes) { if (lower_row.find(prefix + lower_settlement) != std::string::npos) return true; if (lower_row.find(prefix + " " + lower_settlement) != std::string::npos) return true; } return false; } static bool looks_like_dmy_dash(const std::string& s) { return std::regex_match(s, std::regex(R"(\d{2}-\d{2}-\d{4})")); } static bool looks_like_hhmm(const std::string& s) { return std::regex_match(s, std::regex(R"(\d{1,2}:\d{2})")); } // ------------------------- Parser main ------------------------- static std::vector<Outage> parse_outages(const std::string& html_utf8, const std::string& settlement, bool debug=false) { std::vector<Outage> outages; std::regex tr_re(R"(<tr[^>]*>([\s\S]*?)</tr>)", std::regex::icase); std::regex td_re(R"(<td[^>]*>([\s\S]*?)</td>)", std::regex::icase); for (auto tr_it = std::sregex_iterator(html_utf8.begin(), html_utf8.end(), tr_re); tr_it != std::sregex_iterator(); ++tr_it) { std::string tr_body = (*tr_it)[1].str(); if (tr_body.find("<th") != std::string::npos || tr_body.find("</th>") != std::string::npos) continue; std::vector<std::string> cells; for (auto td_it = std::sregex_iterator(tr_body.begin(), tr_body.end(), td_re); td_it != std::sregex_iterator(); ++td_it) { std::string cell = strip_tags((*td_it)[1].str()); cell = remove_uuids(cell); cells.push_back(cell); } if (cells.empty()) continue; std::string row_text; for (auto& c : cells) if (!c.empty()) row_text += c + " "; row_text = collapse_spaces(remove_uuids(row_text)); if (row_text.empty()) continue; if (!contains_settlement(row_text, settlement)) continue; Outage o; if (cells.size() >= 7 && looks_like_dmy_dash(cells[3]) && looks_like_hhmm(cells[4]) && looks_like_dmy_dash(cells[5]) && looks_like_hhmm(cells[6])) { o.date_start_dot = dmy_dash_to_dot(cells[3]); o.time_start = cells[4]; o.date_end_dot = dmy_dash_to_dot(cells[5]); o.time_end = cells[6]; } else { std::smatch m; std::regex re_date(R"(\b(\d{1,2}[.\-]\d{1,2}[.\-]\d{4})\b)"); std::regex re_time(R"(\b(\d{1,2}:\d{2})\b)"); std::string d1, d2, t1, t2; if (std::regex_search(row_text, m, re_date)) { d1 = dmy_dash_to_dot(m[1].str()); std::string tail = row_text.substr((size_t)m.position(1) + (size_t)m.length(1)); std::smatch m2; if (std::regex_search(tail, m2, re_time)) t1 = m2[1].str(); if (std::regex_search(tail, m2, re_date)) { d2 = dmy_dash_to_dot(m2[1].str()); std::string tail2 = tail.substr((size_t)m2.position(1) + (size_t)m2.length(1)); std::smatch m3; if (std::regex_search(tail2, m3, re_time)) t2 = m3[1].str(); } } o.date_start_dot = d1; o.time_start = t1; o.date_end_dot = d2; o.time_end = t2; } if (cells.size() >= 3 && !cells[2].empty()) o.address = cells[2]; else o.address = row_text; if (cells.size() >= 10 && !cells[9].empty()) o.comment = cells[9]; else o.comment.clear(); std::tm tm_start{}; if (parse_dt_dot(o.date_start_dot, o.time_start, tm_start)) { o.start_tm = tm_start; o.start_tm_ok = true; } if (!o.start_tm_ok) continue; outages.push_back(std::move(o)); } if (debug) std::cout << "Total outages found: " << outages.size() << "\n"; return outages; } // ------------------------- Address shorten ------------------------- static std::string address_shorten(std::string a) { a = collapse_spaces(a); a = std::regex_replace(a, std::regex(R"(Ленинградская область\s*)"), ""); a = std::regex_replace(a, std::regex(R"(\s*р-н\s+\S+)"), ""); a = std::regex_replace(a, std::regex(R"(\s*район\s+\S+)"), ""); a = collapse_spaces(a); if (a.size() > 90) { a.resize(90); a += "..."; } return a; } // ------------------------- Template tags ------------------------- static std::string when_label(int days_left, const std::string& date_ddmm) { if (days_left == 0) return "СЕГОДНЯ"; if (days_left == 1) return "ЗАВТРА"; if (days_left >= 2 && days_left <= 9) { std::ostringstream ss; ss << "ЧЕРЕЗ " << days_left << " ДН."; return ss.str(); } return date_ddmm.empty() ? "СКОРО" : date_ddmm; } static std::string badge_label(int days_left) { if (days_left == 0) return "🟥"; if (days_left == 1) return "🟨"; return "⬜"; } static std::string time_range_ascii(const std::string& a, const std::string& b) { if (!a.empty() && !b.empty()) return a + "-" + b; if (!a.empty()) return a; if (!b.empty()) return b; return ""; } static std::string format_message(const json& cfg, const Outage& o) { std::string tmpl = cfg.value("default_message", "⚡ {badge} {when} {time_range} — {address_short}{comment_opt}"); std::tm today = today_date_tm(); int days_left = days_between_dates_local(today, o.start_tm); std::string ddmm = date_dot_to_ddmm(o.date_start_dot); std::string when = when_label(days_left, ddmm); std::string badge = badge_label(days_left); std::string addr_short = address_shorten(o.address); std::string tr = time_range_ascii(o.time_start, o.time_end); std::string comment = collapse_spaces(o.comment); std::string comment_opt = comment.empty() ? "" : (". " + comment); std::string msg = tmpl; msg = replace_all(msg, "{badge}", badge); msg = replace_all(msg, "{when}", when); msg = replace_all(msg, "{days_left}", std::to_string(days_left)); msg = replace_all(msg, "{date}", ddmm); msg = replace_all(msg, "{date_full}", o.date_start_dot); msg = replace_all(msg, "{time_start}", o.time_start); msg = replace_all(msg, "{time_end}", o.time_end); msg = replace_all(msg, "{time_range}", tr); msg = replace_all(msg, "{address}", o.address); msg = replace_all(msg, "{address_short}", addr_short); msg = replace_all(msg, "{comment}", comment); msg = replace_all(msg, "{comment_opt}", comment_opt); msg = remove_uuids(msg); msg = collapse_spaces(msg); int maxlen = cfg.value("max_message_len", 4096); if (maxlen < 40) maxlen = 40; if ((int)msg.size() > maxlen) { msg.resize((size_t)maxlen); msg = collapse_spaces(msg); if (msg.size() + 3 <= (size_t)maxlen) msg += "..."; } return msg; } // ------------------------- Policy ------------------------- static bool is_within_alert_window(const Outage& o, int alert_days) { std::tm today = today_date_tm(); int days_left = days_between_dates_local(today, o.start_tm); return days_left >= 0 && days_left <= alert_days; } static bool time_to_send_today(const json& cfg, bool test_mode_cli) { if (test_mode_cli) return true; if (cfg.value("test_mode", false)) return true; int hh=0, mm=0; std::string st = cfg.value("send_time", "09:00"); if (!parse_hhmm(st, hh, mm)) return true; int nowm = minutes_now_local(); int target = hh * 60 + mm; return nowm >= target; } // ------------------------- Persistence: sent_log.json ------------------------- static json load_sent_log() { std::ifstream f("sent_log.json"); if (!f) return json::object(); try { json j; f >> j; if (!j.is_object()) return json::object(); return j; } catch (...) { return json::object(); } } static void save_sent_log(const json& j) { std::ofstream("sent_log.json", std::ios::trunc) << j.dump(2); } static bool sent_today(json& sent_log, const std::string& day, const std::string& chat_id, const std::string& h) { if (!sent_log.contains(day)) return false; if (!sent_log[day].contains(chat_id)) return false; for (auto& x : sent_log[day][chat_id]) { if (x.is_string() && x.get<std::string>() == h) return true; } return false; } static void mark_sent_today(json& sent_log, const std::string& day, const std::string& chat_id, const std::string& h) { if (!sent_log.contains(day) || !sent_log[day].is_object()) sent_log[day] = json::object(); if (!sent_log[day].contains(chat_id) || !sent_log[day][chat_id].is_array()) sent_log[day][chat_id] = json::array(); sent_log[day][chat_id].push_back(h); } static void cleanup_sent_log(json& sent_log, int keep_days = 14) { if (!sent_log.is_object()) return; std::vector<std::string> keys; for (auto it = sent_log.begin(); it != sent_log.end(); ++it) { if (it.key().size() == 10) keys.push_back(it.key()); } std::sort(keys.begin(), keys.end()); if ((int)keys.size() <= keep_days) return; int drop = (int)keys.size() - keep_days; for (int i = 0; i < drop; ++i) sent_log.erase(keys[i]); } // ------------------------- Telegram: unified ------------------------- static std::string tg_api_base(const json& cfg) { std::string base = trim_ws(cfg.value("telegram_api_url", "https://api.telegram.org")); while (!base.empty() && base.back() == '/') base.pop_back(); // If user mistakenly put /bot... in api_url, strip it auto p = base.find("/bot"); if (p != std::string::npos) base = base.substr(0, p); return base; } static std::string tg_token(const json& cfg) { return trim_ws(cfg.value("telegram_bot_token", "")); } static std::string tg_token_preview(const std::string& token) { if (token.empty()) return "(empty)"; if (token.size() <= 16) return token; return token.substr(0, 6) + "..." + token.substr(token.size()-6); } static std::string tg_url_method(const json& cfg, const std::string& method) { std::string token = tg_token(cfg); if (token.empty()) return ""; return tg_api_base(cfg) + "/bot" + token + "/" + method; } static bool telegram_getMe(const json& cfg, std::string& out_err) { out_err.clear(); std::string url = tg_url_method(cfg, "getMe"); if (url.empty()) { out_err = "telegram_bot_token is empty"; return false; } if (cfg.value("debug_telegram_url", true)) log_line("Telegram URL: " + url); std::string body, err; if (!curl_http_get(url, body, err, 15)) { out_err = err; return false; } if (body.find("\"ok\":true") != std::string::npos) return true; out_err = "Telegram API error: " + body; return false; } static bool send_telegram(const json& cfg, const std::string& chat_id, const std::string& text_any, std::string& out_err, const std::string& parse_mode /* "" or "HTML" */) { out_err.clear(); std::string token = tg_token(cfg); if (token.empty()) { out_err = "telegram_bot_token is empty"; return false; } std::string url = tg_url_method(cfg, "sendMessage"); if (url.empty()) { out_err = "failed to build telegram sendMessage url"; return false; } if (cfg.value("debug_telegram_url", true)) log_line("Telegram URL: " + url); std::string msg = ensure_utf8_for_telegram(text_any); // IMPORTANT: escape both chat_id and text like curl -d does std::string post = "chat_id=" + curl_escape_value(chat_id) + "&text=" + curl_escape_value(msg) + "&disable_web_page_preview=true"; if (!parse_mode.empty()) post += "&parse_mode=" + curl_escape_value(parse_mode); std::string body, err; if (!curl_http_post_form(url, post, body, err, 15)) { out_err = err; return false; } if (body.find("\"ok\":true") != std::string::npos) return true; out_err = "Telegram API error: " + body; return false; } // ------------------------- Test message ------------------------- static bool send_test_message(const json& cfg, const std::string& chat_id, std::string& out_err) { std::time_t now = std::time(nullptr); std::tm* local_time = std::localtime(&now); char time_buf[80]; std::strftime(time_buf, sizeof(time_buf), "%Y-%m-%d %H:%M:%S", local_time); std::string m = "madLENotify — Тестовое сообщение\n\n" "✅ Бот успешно запущен и готов к работе!\n" "🕒 Время сервера: " + std::string(time_buf) + "\n" "📍 Населенный пункт: " + cfg.value("settlement", "Пески") + "\n" "⏰ Период проверки: " + std::to_string(cfg.value("check_interval_sec", 1800) / 60) + " мин.\n" "🔔 Оповещение за: " + std::to_string(cfg.value("alert_days", 3)) + " дня\n"; return send_telegram(cfg, chat_id, m, out_err, ""); } // ------------------------- History table builder ------------------------- static std::string dt_line(const Outage& o, bool is_start) { const std::string& d = is_start ? o.date_start_dot : o.date_end_dot; const std::string& t = is_start ? o.time_start : o.time_end; if (d.empty() && t.empty()) return ""; if (d.empty()) return t; if (t.empty()) return d; return d + " " + t; } static std::vector<std::string> build_history_messages_html(const json& cfg, const std::vector<Outage>& outages) { std::tm today = today_date_tm(); int history_days = cfg.value("history_days", 30); int max_rows = cfg.value("history_max_rows", 200); std::vector<Outage> past; for (const auto& o : outages) { int days_left = days_between_dates_local(today, o.start_tm); if (days_left < 0 && (-days_left <= history_days)) past.push_back(o); } std::sort(past.begin(), past.end(), [](const Outage& a, const Outage& b) { std::tm aa = a.start_tm; std::tm bb = b.start_tm; return mktime(&aa) > mktime(&bb); }); if ((int)past.size() > max_rows) past.resize((size_t)max_rows); if (past.empty()) { return { "🕘 Прошедшие отключения\n\nЗа последние " + std::to_string(history_days) + " дней отключений не найдено." }; } struct Row { std::string a, b; }; std::vector<Row> rows; for (const auto& o : past) rows.push_back({ dt_line(o, true), dt_line(o, false) }); // fixed width for pre const int W = 19; // "dd.mm.yyyy hh:mm" = 16, + запас const int rows_per_msg = 55; std::vector<std::string> out; for (int i = 0; i < (int)rows.size(); i += rows_per_msg) { int j = std::min((int)rows.size(), i + rows_per_msg); std::ostringstream ss; ss << "🕘 Прошедшие отключения\n"; ss << "<pre>"; ss << std::left << std::setw(W) << "Начало работ" << " " << std::setw(W) << "Окончание работ" << "\n"; for (int k = i; k < j; ++k) { std::string a = html_escape(rows[k].a); std::string b = html_escape(rows[k].b); ss << std::left << std::setw(W) << a << " " << std::setw(W) << b << "\n"; } ss << "</pre>\n"; ss << "События определяются по дате МСК; формат источника может меняться."; out.push_back(ss.str()); } return out; } // ------------------------- Save last html ------------------------- static void save_last_html(const std::string& html_utf8) { std::ofstream("last.html", std::ios::trunc) << html_utf8; } // ------------------------- HTTP API ------------------------- static void start_api_thread(json& config, std::mutex& config_mtx) { std::thread([&config, &config_mtx]() { httplib::Server svr; svr.Get("/config", [&config, &config_mtx](const httplib::Request&, httplib::Response& res) { std::lock_guard<std::mutex> lk(config_mtx); res.set_content(config.dump(2), "application/json"); }); svr.Post("/config", [&config, &config_mtx](const httplib::Request& req, httplib::Response& res) { try { json new_cfg = json::parse(req.body); ensure_config_defaults(new_cfg); { std::lock_guard<std::mutex> lk(config_mtx); config = new_cfg; std::ofstream("settings.json", std::ios::trunc) << config.dump(2); } log_line("Config updated via API -> settings.json"); res.set_content("Updated", "text/plain"); } catch (...) { res.status = 400; res.set_content("Invalid JSON", "text/plain"); } }); int port = 8080; { std::lock_guard<std::mutex> lk(config_mtx); port = config.value("api_port", 8080); } std::cout << "🌐 madLENotify API запущен на порту " << port << "\n"; log_line("madLENotify API started on port " + std::to_string(port)); bool ok = svr.listen("0.0.0.0", port); log_line(std::string("API listen result: ") + (ok ? "OK" : "FAIL")); }).detach(); } // ------------------------- History mode ------------------------- static void run_history_mode(json& config, std::mutex& config_mtx) { json cfg; { std::lock_guard<std::mutex> lk(config_mtx); cfg = config; } std::cout << "madLENotify: Запуск режима истории...\n"; // TG check { std::string tgerr; bool ok = telegram_getMe(cfg, tgerr); if (!ok) { log_line("Telegram getMe FAILED: " + tgerr); std::cerr << "Ошибка Telegram (getMe): " << tgerr << "\n"; return; } log_line("Telegram getMe OK"); } std::string url = build_planned_work_url(cfg); std::cout << "URL: " << url << "\n"; std::string html = fetch_html(url); if (html.empty()) { std::cerr << "Ошибка: не удалось скачать страницу\n"; const auto chat_ids = cfg["chat_ids"].get<std::vector<std::string>>(); for (const auto& chat_id : chat_ids) { std::string err; send_telegram(cfg, chat_id, "madLENotify: Не удалось скачать данные с сайта Ленэнерго\nURL: " + url, err, ""); } return; } if (meta_says_cp1251(html) || !is_valid_utf8(html)) { log_line("History: converting HTML to UTF-8 (cp1251 fallback)"); html = cp1251_to_utf8(html); } else { html = ensure_utf8_for_telegram(html); } save_last_html(html); std::cout << "Страница скачана (" << html.size() << " байт)\n"; std::string settlement = cfg.value("settlement", "Пески"); auto outages = parse_outages(html, settlement, false); std::cout << "НАЙДЕНО ОТКЛЮЧЕНИЙ: " << outages.size() << "\n"; auto msgs = build_history_messages_html(cfg, outages); const auto chat_ids = cfg["chat_ids"].get<std::vector<std::string>>(); for (const auto& chat_id : chat_ids) { for (const auto& m : msgs) { std::string err; bool ok = send_telegram(cfg, chat_id, m, err, "HTML"); if (!ok) { log_line("Failed to send history chunk to chat " + chat_id + ": " + err); std::cerr << "Ошибка отправки history: " << err << "\n"; break; } std::this_thread::sleep_for(std::chrono::milliseconds(250)); } } } // ------------------------- One cycle ------------------------- static void run_one_cycle(json& config, std::mutex& config_mtx, json& sent_log, bool test_mode_cli, bool force_cli, bool debug_mode) { json cfg; { std::lock_guard<std::mutex> lk(config_mtx); cfg = config; } std::cout << "madLENotify: Начало проверки...\n"; // TG check { std::string tgerr; bool ok = telegram_getMe(cfg, tgerr); if (!ok) { log_line("Telegram getMe FAILED: " + tgerr); std::cerr << "Ошибка Telegram (getMe): " << tgerr << "\n"; return; } log_line("Telegram getMe OK"); } std::string today = today_ymd(); std::string last_day; int sent_count = 0; load_daily_sent("daily_sent.txt", last_day, sent_count); if (last_day != today) sent_count = 0; int daily_limit = cfg.value("daily_limit", 5); std::cout << "Отправлено сегодня: " << sent_count << " (лимит " << daily_limit << ")\n"; std::string url = build_planned_work_url(cfg); std::cout << "URL: " << url << "\n"; std::string html = fetch_html(url); if (html.empty()) { log_line("Fetch failed: empty HTML"); std::cout << "Ошибка загрузки страницы\n"; const auto chat_ids = cfg["chat_ids"].get<std::vector<std::string>>(); for (const auto& chat_id : chat_ids) { std::string err; send_telegram(cfg, chat_id, "madLENotify: Ошибка загрузки данных\nURL: " + url, err, ""); } return; } if (meta_says_cp1251(html) || !is_valid_utf8(html)) { log_line("Cycle: converting HTML to UTF-8 (cp1251 fallback)"); html = cp1251_to_utf8(html); } else { html = ensure_utf8_for_telegram(html); } save_last_html(html); std::cout << "Страница скачана (размер: " << html.size() << " байт)\n"; std::string settlement = cfg.value("settlement", "Пески"); auto outages = parse_outages(html, settlement, debug_mode); int alert_days = cfg.value("alert_days", 3); std::vector<Outage> eligible; for (const auto& o : outages) if (is_within_alert_window(o, alert_days)) eligible.push_back(o); std::cout << "Найдено отключений: " << outages.size() << ", подходящих (в пределах " << alert_days << " дней): " << eligible.size() << "\n"; if (!time_to_send_today(cfg, test_mode_cli)) { std::cout << "Не время для отправки (send_time=" << cfg.value("send_time", "09:00") << ")\n"; log_line("Not time to send yet"); return; } const auto chat_ids = cfg["chat_ids"].get<std::vector<std::string>>(); for (const auto& o : eligible) { std::string h = o.hash64_hex(); for (const auto& chat_id : chat_ids) { if (!test_mode_cli && sent_count >= daily_limit) { log_line("Daily limit reached"); std::cout << "Достигнут дневной лимит сообщений\n"; goto DONE_SENDING; } if (!force_cli && sent_today(sent_log, today, chat_id, h)) { std::cout << "Сообщение уже отправлено сегодня (hash: " << h.substr(0, 8) << "...)\n"; continue; } std::string msg = format_message(cfg, o); std::cout << "Отправка -> chat " << chat_id << ": " << msg << "\n"; std::string err; bool ok = send_telegram(cfg, chat_id, msg, err, ""); if (!ok) { std::cout << "Ошибка отправки в chat " << chat_id << "\n"; log_line("Send failed to chat " + chat_id + ": " + err); std::cerr << "DETAILS:\n" << err << "\n"; } else { sent_count++; mark_sent_today(sent_log, today, chat_id, h); log_line("Sent to chat " + chat_id + ": " + msg); std::cout << "Сообщение отправлено в chat " << chat_id << "\n"; } } } DONE_SENDING: cleanup_sent_log(sent_log, 14); save_sent_log(sent_log); save_daily_sent("daily_sent.txt", today, sent_count); std::cout << "madLENotify: Проверка завершена\n"; std::cout << " Найдено отключений: " << outages.size() << "\n"; std::cout << " В пределах " << alert_days << " дней: " << eligible.size() << "\n"; std::cout << " Отправлено сообщений: " << sent_count << "\n"; } // ------------------------- main ------------------------- int main(int argc, char** argv) { bool once = false; bool test_cli = false; bool force_cli = false; bool test_send = false; bool debug_mode = false; bool history_mode = false; bool tg_check = false; std::string config_path; for (int i = 1; i < argc; ++i) { std::string a = argv[i]; if (a == "--man" || a == "--help") { std::cout << MAN_TEXT; return 0; } else if (a == "--once") { once = true; } else if (a == "--test") { test_cli = true; } else if (a == "--force") { force_cli = true; } else if (a == "--test-send") { test_send = true; } else if (a == "--debug") { debug_mode = true; } else if (a == "--history") { history_mode = true; } else if (a == "--tg-check") { tg_check = true; } else if (a == "--config") { if (i + 1 >= argc) { std::cerr << "Ошибка: --config требует путь к файлу\n"; return 2; } config_path = argv[++i]; } else { std::cerr << "Неизвестная опция: " << a << "\n\n" << MAN_TEXT; return 2; } } curl_global_init(CURL_GLOBAL_DEFAULT); std::cout << "madLENotify v1.5 — Уведомления об отключениях Ленэнерго\n"; std::cout << "=====================================================\n"; // choose config if (config_path.empty()) { if (file_exists("settings.json")) config_path = "settings.json"; else config_path = "config.json"; } json config; std::string err; if (!load_config_from_file(config_path, config, err)) { std::cerr << "Ошибка загрузки " << config_path << ": " << err << "\n"; curl_global_cleanup(); return 1; } // Print what we loaded std::string token = tg_token(config); std::cout << "Файл настроек: " << config_path << "\n"; std::cout << "Населенный пункт: " << config.value("settlement", "Пески") << "\n"; std::cout << "URL: " << build_planned_work_url(config) << "\n"; std::cout << "Интервал проверки: " << config.value("check_interval_sec", 1800) << " сек.\n"; std::cout << "Оповещение за: " << config.value("alert_days", 3) << " дня\n"; std::cout << "History days: " << config.value("history_days", 30) << "\n"; std::cout << "History max rows: " << config.value("history_max_rows", 200) << "\n"; std::cout << "Telegram api: " << tg_api_base(config) << "\n"; std::cout << "Telegram token preview: " << tg_token_preview(token) << " (len=" << token.size() << ")\n"; if (!config["chat_ids"].is_array() || config["chat_ids"].empty()) { std::cout << "⚠ chat_ids пустой! Добавь chat_ids: [\"-100...\"] или tg_chat_id\n"; } else { std::cout << "chat_ids: " << config["chat_ids"].dump() << "\n"; } if (tg_check) { std::string e; bool ok = telegram_getMe(config, e); if (ok) { std::cout << "Telegram getMe: OK\n"; log_line("Telegram getMe: OK"); curl_global_cleanup(); return 0; } std::cerr << "Telegram getMe: FAIL: " << e << "\n"; log_line("Telegram getMe: FAIL: " + e); curl_global_cleanup(); return 1; } if (test_send) { std::cout << "\nОтправка тестового сообщения...\n"; std::string e; if (!telegram_getMe(config, e)) { std::cerr << "Ошибка Telegram (getMe): " << e << "\n"; log_line("Telegram getMe FAILED: " + e); curl_global_cleanup(); return 1; } const auto chat_ids = config["chat_ids"].get<std::vector<std::string>>(); bool any_sent = false; for (const auto& chat_id : chat_ids) { std::string se; bool ok = send_test_message(config, chat_id, se); if (ok) { std::cout << "Тестовое сообщение отправлено в chat " << chat_id << "\n"; log_line("Test message sent to chat " + chat_id); any_sent = true; } else { std::cerr << "Ошибка отправки в chat " << chat_id << ": " << se << "\n"; log_line("Failed to send test message to chat " + chat_id + ": " + se); } } curl_global_cleanup(); return any_sent ? 0 : 1; } if (history_mode) { std::mutex config_mtx; run_history_mode(config, config_mtx); curl_global_cleanup(); return 0; } std::mutex config_mtx; start_api_thread(config, config_mtx); std::cout << "\nКонфигурация загружена\n"; json sent_log = load_sent_log(); while (true) { std::cout << "\n" << std::string(50, '=') << "\n"; run_one_cycle(config, config_mtx, sent_log, test_cli, force_cli, debug_mode); if (once) { std::cout << "\nЗавершено (--once)\n"; break; } json cfg; { std::lock_guard<std::mutex> lk(config_mtx); cfg = config; } int sleep_sec = cfg.value("check_interval_sec", 1800); std::cout << "\nОжидание " << sleep_sec << " секунд...\n"; std::this_thread::sleep_for(std::chrono::seconds(sleep_sec)); } curl_global_cleanup(); std::cout << "\nmadLENotify завершает работу\n"; return 0; }
🧷 Запуск как systemd-сервис
Чтобы демон работал постоянно и автоматически стартовал после перезагрузки, оформим его как systemd unit 🧩.
📄 Создаём юнит
Обычно кладём файл в /etc/systemd/system/lenotify.service. Внутри задаём путь к бинарнику, рабочую директорию, перезапуск и вывод в журнал.
[Unit] Description=LENotify - Telegram notifications for Leningrad power outages After=network.target [Service] Type=simple User=root WorkingDirectory=/root Environment=TZ=Europe/Moscow ExecStart=/root/lenotify Restart=always RestartSec=30 StandardOutput=journal StandardError=journal SyslogIdentifier=lenotify # Ограничения ресурсов MemoryMax=512M CPUQuota=50% [Install] WantedBy=multi-user.target
✅ Включаем и запускаем
🚀 Команды включения юнита и т. п.
# Перезагружаем systemd sudo systemctl daemon-reload # Включаем автозапуск sudo systemctl enable lenotify # Запускаем сервис sudo systemctl start lenotify # Проверяем статус sudo systemctl status lenotify # Смотрим логи sudo journalctl -u lenotify -f
🛠 Полезные команды
Когда сервис живёт в systemd, самое полезное — смотреть статус и журнал. Плюс иногда нужно перезагрузить конфиги systemd после правок.
# Остановить сервис sudo systemctl stop lenotify # Перезапустить сервис sudo systemctl restart lenotify # Проверить логи sudo journalctl -u lenotify -n 50 # Следить за логами в реальном времени sudo journalctl -u lenotify -f # Проверить, запущен ли sudo systemctl is-active lenotify
🧪 Режимы запуска и отладка
- 🔁
./lenotify— обычный режим: вечный демон, периодически проверяет и шлёт. - 🕐
./lenotify --once— один цикл проверки и выход (удобно для отладки). - 🧪
./lenotify --test --force --once— отладка “в лоб”: игнорирует send_time и историю отправок. - 📨
./lenotify --test-send— тестовое сообщение (проверка Telegram). - 🧾
./lenotify --history— отправка истории прошедших отключений. - 🤖
./lenotify --tg-check— проверка токена через Telegram getMe. - 🪵
./lenotify --debug— более подробный вывод по парсингу.
📌 Советы по эксплуатации
- 🔒 Не публикуй токен бота. Если засветился — перевыпусти в @BotFather.
- 🧾 Проверь, что
chat_idsзаполнен (например,"-100..."для каналов/супергрупп). - 🕘 Убедись, что TZ на сервере корректный (или задай
Environment=TZ=Europe/Moscowв юните). - 🧰 Если что-то “не шлёт”, сначала смотри журнал systemd — там обычно всё видно.
🏁 Итого
madLENotify — простой, понятный и автономный способ получать уведомления об отключениях электричества в Telegram. Он живёт как сервис, пишет логи, умеет историю и не превращает чат в мусорку благодаря учёту отправок 🧯.
