#!/usr/bin/env python3 """ Delta Chat Bot using recommended JSON-RPC bindings. """ import base64 import html import json import locale import logging import os import re import smtplib import ssl import imaplib import psutil import requests import sys import tempfile import threading import time import socket import uuid import xml.etree.ElementTree as ET from datetime import datetime, timedelta, timezone from typing import Optional from deltachat_rpc_client import Bot, DeltaChat, EventType, Rpc, events, run_bot_cli import ai_agent try: locale.setlocale(locale.LC_TIME, 'ru_RU.UTF-8') except locale.Error: try: locale.setlocale(locale.LC_TIME, 'ru_RU.utf8') except locale.Error: pass logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) ACCOUNTS_DIR = "/home/alexabudaev/delta-bot/accounts" CONFIG_DIR = os.path.expanduser("~/.config/deltabot") SUBSCRIBERS_FILE = os.path.join(CONFIG_DIR, "subscribers.json") PENDING_TOR_FILE = os.path.join(CONFIG_DIR, "pending_tor.json") BRIDGES_CACHE_FILE = os.path.join(CONFIG_DIR, "bridges_cache.json") TG_CHANNELS_FILE = os.path.join(CONFIG_DIR, "telegram_channels.json") PENDING_SAVE_FILE = os.path.join(CONFIG_DIR, "pending_save.json") PENDING_CHANNEL_FILE = os.path.join(CONFIG_DIR, "pending_channel.json") BRIDGES_CACHE_TTL = 6 * 3600 TG_RSS_PROXY = "https://proxy.budaev.org/rss" TG_RSS_FALLBACK = "https://tg.i-c-a.su/rss" TG_POLL_INTERVAL = 90 HELP_TEXT = ( "Доступные команды:\n" "/status - Состояние сервера\n" "/weather [город] [дни] - Погода. Дни: 3 (по умолч.), 7, 10\n" "/bridges [obfs4|vanilla|ipv6] - Запрос Tor мостов\n" "/save [путь] - Сохранить файл (прикрепите вложение)\n" "/channels list|add|remove|description|image - Управление каналами\n" "/telegram username [N] - Посты из TG канала\n" "/ai on|off|status|reset|summary - AI чат-бот\n" "/model - Сменить AI модель\n" "/apikey [ключ] - Ключ OpenRouter (для fallback)\n" "/cal today|week|list|add|delete - Управление календарём\n" "/note add|list|delete - Заметки\n" "/rate [валюта] - Курс валют ЦБ РФ\n" "/ip [адрес] - Информация об IP\n" "/dns <домен> - DNS-запрос\n" "/monitor add|list|remove|check - Мониторинг сайтов\n" "/subscribe - Подписаться на ежедневный отчет (09:00 Irkutsk)\n" "/unsubscribe - Отписаться от отчетов\n" "/qr - QR код для добавления бота\n" "/join https://i.delta.chat/#... - Secure join по ссылке\n" "/addcontact - Добавить контакт в белый список\n" "/contacts list - Список разрешённых контактов\n" "/help - Эта справка" ) DEFAULT_CITY = "Ulan-Ude" DEFAULT_SAVE_DIRS = ["/tmp/", os.path.expanduser("~/")] BOT_DIR = os.path.expanduser("~/delta-bot") SAVE_DIR = os.path.expanduser("~/delta-chat") CALDAV_URL = os.environ.get("CALDAV_URL", "https://baikal.budaev.org/dav.php/calendars/alex@budaev.org/ai-notifications/") CALDAV_USER = os.environ.get("CALDAV_USER", "") CALDAV_PASSWORD = os.environ.get("CALDAV_PASSWORD", "") CAL_REMINDERS_FILE = os.path.join(CONFIG_DIR, "cal_reminders_sent.json") CAL_REMINDER_AHEAD = 15 * 60 CAL_REMINDER_INTERVAL = 5 * 60 NOTES_FILE = os.path.join(CONFIG_DIR, "notes.json") MONITORS_FILE = os.path.join(CONFIG_DIR, "monitors.json") ALLOWED_CONTACTS_FILE = os.path.join(CONFIG_DIR, "allowed_contacts.json") MONITOR_INTERVAL = 5 * 60 MONITOR_TIMEOUT = 10 CBR_RATES_URL = "https://www.cbr.ru/scripts/XML_daily.asp" _rates_cache = {"ts": 0.0, "data": {}} GMAIL_EMAIL = os.environ.get("GMAIL_EMAIL", "") GMAIL_APP_PASSWORD = os.environ.get("GMAIL_APP_PASSWORD", "") GMAIL_IMAP_HOST = "imap.gmail.com" GMAIL_IMAP_PORT = 993 GMAIL_SMTP_HOST = "smtp.gmail.com" GMAIL_SMTP_PORT = 465 TOR_EMAIL = "bridges@torproject.org" IRKUTSK_TZ = timezone(timedelta(hours=8)) os.makedirs(CONFIG_DIR, exist_ok=True) def load_json(path, default=None): if os.path.exists(path): try: with open(path, 'r') as f: return json.load(f) except Exception as e: logger.error(f"Error loading {path}: {e}") return default if default is not None else {} def save_json(path, data): try: os.makedirs(os.path.dirname(path), exist_ok=True) with open(path, 'w') as f: json.dump(data, f, indent=2, default=str) logger.info(f"Saved JSON to {path}") except Exception as e: logger.error(f"Error saving {path}: {e}") def parse_natural_language(text): lower = text.lower().strip() m = re.match(r'(?:погод[ауе]|weather|прогноз)\s*(.+)?$', lower) if m: rest = (m.group(1) or '').strip() return f"/weather {rest}".rstrip() m = re.match(r'(?:мосты?|bridge|tor)', lower) if m: for t in ['ipv6', 'vanilla', 'obfs4']: if t in lower: return f"/bridges {t}" return "/bridges obfs4" m = re.match(r'(?:дай|дайте|нужны)\s+(?:мосты?|bridge|tor)', lower) if m: for t in ['ipv6', 'vanilla', 'obfs4']: if t in lower: return f"/bridges {t}" return "/bridges obfs4" m = re.match(r'(?:заметка|запомни|note|запиши)\s+(.+)$', lower) if m: return f"/note add {text[m.start(1):]}" m = re.match(r'(?:заметки|мои заметки|list notes)', lower) if m: return "/note list" m = re.match(r'(?:напомни|remind(?:er)?)\s+(.+)$', lower) if m: return f"/cal add {text[m.start(1):]}" # Календарь m = re.match(r'(?:события?|calendar|календарь)\s*(?:на\s*)?(сегодня|today)$', lower) if m: return "/cal today" m = re.match(r'(?:события?|calendar|календарь)\s*(?:на\s*)?(неделю?|week)$', lower) if m: return "/cal week" m = re.match(r'(?:события?|calendar|календарь)$', lower) if m: return "/cal today" # Курс валют m = re.match(r'(?:курс|валюта|rate|exchange)\s*(.*)$', lower) if m: rest = m.group(1).strip().upper() return f"/rate {rest}".rstrip() # IP / DNS m = re.match(r'(?:мой\s+)?ip(?:\s+адрес)?$', lower) if m: return "/ip" m = re.match(r'ip\s+(.+)$', lower) if m: return f"/ip {text[m.start(1):]}" m = re.match(r'(?:dns|домен)\s+(.+)$', lower) if m: return f"/dns {text[m.start(1):]}" # Мониторинг m = re.match(r'(?:мониторинг|monitor)$', lower) if m: return "/monitor list" m = re.match(r'(?:следи|мониторь|monitor)\s+(?:за\s+)?(.+)$', lower) if m: return f"/monitor add {text[m.start(1):]}" # AI control m = re.match(r'(?:включи|вкл)\s+(?:ai|ии|чат)', lower) if m: return "/ai on" m = re.match(r'(?:выключи|выкл|выруби)\s+(?:ai|ии|чат)', lower) if m: return "/ai off" m = re.match(r'(?:ai|ии|чат|chat)\s+(?:статус|status|состояние)', lower) if m: return "/ai status" m = re.match(r'(?:ai|ии|чат|chat)\s+(?:сброс|reset|очисти|clear)', lower) if m: return "/ai reset" # Model m = re.match(r'(?:смени\s+)?модель', lower) if m: return "/model" # Telegram feed m = re.match(r'(?:telegram|телеграм|посты)\s*(.+)?$', lower) if m: rest = (m.group(1) or '').strip() return f"/telegram {rest}".rstrip() # Channels m = re.match(r'(?:каналы?|channels?)', lower) if m: return "/channels list" simple = { 'статус': '/status', 'status': '/status', 'состояние': '/status', 'сервер': '/status', 'помощь': '/help', 'help': '/help', 'команды': '/help', 'что умеешь': '/help', 'бот': '/help', 'инфо': '/help', 'подпишись': '/subscribe', 'подписаться': '/subscribe', 'subscribe': '/subscribe', 'отпишись': '/unsubscribe', 'отписаться': '/unsubscribe', 'unsubscribe': '/unsubscribe', 'qr': '/qr', 'кьюар': '/qr', 'qrcode': '/qr', } return simple.get(lower) or None def load_subscribers(): return set(tuple(x) for x in load_json(SUBSCRIBERS_FILE, [])) def save_subscribers(subscribers): save_json(SUBSCRIBERS_FILE, [list(s) for s in subscribers]) def load_pending_tor(): return load_json(PENDING_TOR_FILE, []) def save_pending_tor(pending): save_json(PENDING_TOR_FILE, pending) def load_bridges_cache(): return load_json(BRIDGES_CACHE_FILE, {}) def save_bridges_cache(cache): save_json(BRIDGES_CACHE_FILE, cache) def load_telegram_channels(): return load_json(TG_CHANNELS_FILE, {}).get('channels', []) def save_telegram_channels(channels): save_json(TG_CHANNELS_FILE, {'channels': channels}) def load_pending_save(): return load_json(PENDING_SAVE_FILE, {}) def save_pending_save(data): save_json(PENDING_SAVE_FILE, data) def get_pending_save(chat_id): return load_pending_save().get(str(chat_id)) def set_pending_save(chat_id, file_name, path, base64_data): logger.info(f"set_pending_save called: chat_id={chat_id}") data = load_pending_save() data[str(chat_id)] = {"file_name": file_name, "path": path, "base64": base64_data} logger.info(f"Saving pending: {data}") save_pending_save(data) logger.info(f"File saved, exists: {os.path.exists(PENDING_SAVE_FILE)}") def clear_pending_save(chat_id): data = load_pending_save() if str(chat_id) in data: del data[str(chat_id)] save_pending_save(data) def load_pending_channel(): return load_json(PENDING_CHANNEL_FILE, {}) def save_pending_channel(data): save_json(PENDING_CHANNEL_FILE, data) def get_pending_channel(chat_id): return load_pending_channel().get(str(chat_id)) def set_pending_channel(chat_id, username, broadcast_chat_id, action): data = load_pending_channel() data[str(chat_id)] = {"username": username, "broadcast_chat_id": broadcast_chat_id, "action": action} save_pending_channel(data) def clear_pending_channel(chat_id): data = load_pending_channel() data.pop(str(chat_id), None) save_pending_channel(data) def get_telegram_feed(username, limit=10, retry_count=1): proxies = [TG_RSS_PROXY] if TG_RSS_FALLBACK: proxies.append(TG_RSS_FALLBACK) for proxy in proxies: for attempt in range(retry_count): try: url = f"{proxy}/{username}?limit={limit}" resp = requests.get(url, timeout=45, headers={'User-Agent': 'Mozilla/5.0', 'Accept': '*/*'}) if not resp.text or len(resp.text) < 10: if attempt < retry_count - 1: time.sleep(10) continue root = ET.fromstring(resp.text) items = root.findall('.//item') channel_title = root.findtext('.//title', '') channel_description = root.findtext('.//description', '') or f"Ретрансляция канала @{username}" channel_image = root.findtext('.//image') or '' posts = [] for item in items: post = {'title': item.findtext('title', ''), 'link': item.findtext('link', ''), 'description': item.findtext('description', '')} enclosure = item.find('enclosure') if enclosure is not None: post['enclosure_url'] = enclosure.get('url', '') post['enclosure_type'] = enclosure.get('type', '') post['enclosure_length'] = enclosure.get('length', '') posts.append(post) if posts: logger.info(f"TG Feed: {proxy} -> {username}: {len(posts)} posts") return posts, channel_title, channel_description, channel_image, None if attempt < retry_count - 1: time.sleep(5) continue return None, None, "", "", f"Нет постов в ленте (@{username})" except ET.ParseError: if attempt < retry_count - 1: time.sleep(5) continue except Exception as e: logger.warning(f"TG Feed: {proxy} failed for {username}: {e}") if attempt < retry_count - 1: time.sleep(5) continue return None, None, "", "", f"Все прокси недоступны для @{username}" WTTR_CODES = { 113: "Ясно", 116: "Частично облачно", 119: "Облачно", 122: "Пасмурно", 143: "Туман", 248: "Туман", 260: "Ледяной туман", 176: "Небольшой дождь", 185: "Морозная морось", 200: "Гроза", 227: "Метель", 230: "Буран", 263: "Лёгкая морось", 266: "Морось", 281: "Ледяная морось", 284: "Сильная ледяная морось", 293: "Слабый дождь", 296: "Дождь", 299: "Умеренный дождь", 302: "Сильный дождь", 305: "Сильный дождь", 308: "Очень сильный дождь", 311: "Мокрый снег", 314: "Умеренный мокрый снег", 317: "Слабый мокрый снег", 320: "Умеренный снег", 323: "Слабый снег", 326: "Умеренный снег", 329: "Сильный снег", 332: "Сильный снег", 335: "Очень сильный снег", 338: "Экстремальный снег", 350: "Ледяная крупа", 353: "Ливень", 356: "Умеренный ливень", 359: "Сильный ливень", 362: "Мокрый снег с дождём", 365: "Мокрый снег с дождём", 368: "Снегопад", 371: "Сильный снегопад", 386: "Гроза с дождём", 389: "Сильная гроза с дождём", 392: "Гроза со снегом", 395: "Сильная гроза со снегом", } def get_weather_wttr(city=DEFAULT_CITY, forecast_days=3): """Запасной источник погоды — wttr.in JSON API.""" url = f"https://wttr.in/{requests.utils.quote(city)}?format=j1&lang=ru" resp = requests.get(url, timeout=25) resp.raise_for_status() data = resp.json() cc = data["current_condition"][0] temp = cc["temp_C"] feels = cc["FeelsLikeC"] humidity = cc["humidity"] wind_kmh = float(cc["windspeedKmph"]) wind_ms = round(wind_kmh / 3.6, 1) pressure = cc.get("pressure", "?") code = int(cc["weatherCode"]) desc = WTTR_CODES.get(code, cc.get("weatherDesc", [{}])[0].get("value", f"Код {code}")) months_ru = { 'january': 'января', 'february': 'февраля', 'march': 'марта', 'april': 'апреля', 'may': 'мая', 'june': 'июня', 'july': 'июля', 'august': 'августа', 'september': 'сентября', 'october': 'октября', 'november': 'ноября', 'december': 'декабря' } days_ru = { 'monday': 'понедельник', 'tuesday': 'вторник', 'wednesday': 'среда', 'thursday': 'четверг', 'friday': 'пятница', 'saturday': 'суббота', 'sunday': 'воскресенье' } msg = f"Погода в {city.title()}\n" msg += f"Сейчас: {temp}°C, {desc}\n" msg += f"Ощущается: {feels}°C\n" msg += f"Влажность: {humidity}%, Ветер: {wind_ms} м/с\n" msg += f"Давление: {pressure} гПа\n\n" days_to_show = min(forecast_days, len(data.get("weather", []))) days_label = {3: "3 дня", 7: "7 дней", 10: "10 дней"}.get(forecast_days, f"{forecast_days} дней") msg += f"Прогноз на {days_label}:\n" for day in data["weather"][:days_to_show]: date = datetime.strptime(day["date"], "%Y-%m-%d") day_name = date.strftime("%d %B, %A").lower() for en, ru in months_ru.items(): day_name = day_name.replace(en, ru) for en, ru in days_ru.items(): day_name = day_name.replace(en, ru) tmax = day["maxtempC"] tmin = day["mintempC"] noon = day.get("hourly", [{}] * 5)[4] day_code = int(noon.get("weatherCode", 0)) day_desc = WTTR_CODES.get(day_code, noon.get("weatherDesc", [{}])[0].get("value", "")) msg += f"{day_name}: {tmin}...{tmax}°C, {day_desc}\n" return msg def get_weather(city=DEFAULT_CITY, lat=None, lon=None, forecast_days=3, retry_count=3): try: city_name = city if lat is None or lon is None: geo_url = f"https://geocoding-api.open-meteo.com/v1/search?name={city}&count=1&language=ru" geo_resp = requests.get(geo_url, timeout=5) if geo_resp.status_code != 200: raise RuntimeError(f"геокодинг: код {geo_resp.status_code}") geo_data = geo_resp.json() if "results" not in geo_data or not geo_data["results"]: return f"Город '{city}' не найден." lat = geo_data["results"][0]["latitude"] lon = geo_data["results"][0]["longitude"] city_name = geo_data["results"][0].get("name", city) else: city_name = city weather_hosts = [ "customer-api-eu03.open-meteo.com", "customer-api-eu02.open-meteo.com", ] weather_data = None for host in weather_hosts: try: url = ( f"https://{host}/v1/forecast?" f"latitude={lat}&longitude={lon}" f"¤t=temperature_2m,relative_humidity_2m,apparent_temperature,weather_code,wind_speed_10m,pressure_msl" f"&daily=temperature_2m_max,temperature_2m_min,weather_code" f"&timezone=auto&forecast_days={forecast_days}" ) resp = requests.get(url, timeout=10) if resp.status_code == 200: weather_data = resp.json() break except Exception: continue if weather_data is None: raise RuntimeError("все endpoint'ы open-meteo недоступны") current = weather_data["current"] daily = weather_data["daily"] weather_codes = { 0: "Ясно ☀️", 1: "Преимущественно ясно 🌤️", 2: "Частично облачно ⛅", 3: "Пасмурно ☁️", 45: "Туман 🌫️", 48: "Иней/туман 🌫️", 51: "Легкая морось 🌦️", 53: "Морось 🌦️", 55: "Сильная морось 🌧️", 61: "Легкий дождь 🌧️", 63: "Дождь 🌧️", 65: "Сильный дождь 🌧️", 71: "Легкий снег 🌨️", 73: "Снег 🌨️", 75: "Сильный снег 🌨️", 80: "Ливень 🌧️", 81: "Сильный ливень 🌧️", 82: "Экстремальный ливень 🌧️", 95: "Гроза ⛈️", 96: "Гроза с градом ⛈️", 99: "Сильная гроза с градом ⛈️" } months_ru = {'january': 'января', 'february': 'февраля', 'march': 'марта', 'april': 'апреля', 'may': 'мая', 'june': 'июня', 'july': 'июля', 'august': 'августа', 'september': 'сентября', 'october': 'октября', 'november': 'ноября', 'december': 'декабря'} days_ru = {'wednesday': 'среда', 'thursday': 'четверг', 'friday': 'пятница', 'saturday': 'суббота', 'sunday': 'воскресенье', 'monday': 'понедельник', 'tuesday': 'вторник'} weather_code = current['weather_code'] weather_desc = weather_codes.get(weather_code, f'Код {weather_code}') days_label = {3: "3 дня", 7: "7 дней", 10: "10 дней"}.get(forecast_days, f"{forecast_days} дней") msg = f"🌤 Погода в {city_name}\n" msg += f"Сейчас: {current['temperature_2m']}°C, {weather_desc}\n" feels_like = current.get('apparent_temperature') if feels_like is not None: msg += f"Ощущается: {feels_like}°C\n" msg += f"Влажность: {current['relative_humidity_2m']}%, Ветер: {current['wind_speed_10m']} м/с\n" pressure = current.get('pressure_msl') if pressure is not None: msg += f"Давление: {pressure} гПа\n" msg += "\n" msg += f"📅 Прогноз на {days_label}:\n" for i in range(min(forecast_days, len(daily["time"]))): date_str = daily["time"][i] date = datetime.strptime(date_str, "%Y-%m-%d") day_name = date.strftime("%d %B, %A").lower() for en, ru in months_ru.items(): day_name = day_name.replace(en, ru) for en, ru in days_ru.items(): day_name = day_name.replace(en, ru) tmax = daily["temperature_2m_max"][i] tmin = daily["temperature_2m_min"][i] wcode = daily["weather_code"][i] msg += f"{day_name}: {tmin}...{tmax}°C, {weather_codes.get(wcode, f'Код {wcode}')}\n" return msg except Exception as e: logger.warning(f"open-meteo failed ({e}), trying wttr.in") try: return get_weather_wttr(city_name, forecast_days) except Exception as e2: return f"Ошибка погоды: open-meteo недоступен, wttr.in: {e2}" def get_system_status(): try: cpu = psutil.cpu_percent(interval=1) load = psutil.getloadavg() mem = psutil.virtual_memory() disk = psutil.disk_usage('/') uptime = datetime.fromtimestamp(psutil.boot_time()) uptime_delta = datetime.now() - uptime days = uptime_delta.days hours, remainder = divmod(uptime_delta.seconds, 3600) minutes = remainder // 60 return ( f"🖥 Server Status\n" f"Uptime: {days}d {hours}h {minutes}m\n" f"Load: {load[0]:.1f}, {load[1]:.1f}, {load[2]:.1f}\n" f"CPU: {cpu}%\n" f"RAM: {mem.percent}% ({mem.used // 1024 // 1024}MB / {mem.total // 1024 // 1024}MB)\n" f"Disk: {disk.percent}% ({disk.used // 1024 // 1024 // 1024}GB / {disk.total // 1024 // 1024 // 1024}GB)" ) except Exception as e: return f"Error getting status: {e}" def _remove_day_keyword(raw): for kw in ['на неделю', 'неделя', 'week', 'на 10 дней', 'десять дней']: raw = raw.replace(kw, '') return raw.strip() def smtp_send(to, subject, body): try: context = ssl.create_default_context() server = smtplib.SMTP_SSL(GMAIL_SMTP_HOST, GMAIL_SMTP_PORT, context=context, timeout=30) server.login(GMAIL_EMAIL, GMAIL_APP_PASSWORD) msg = f"From: {GMAIL_EMAIL}\r\nTo: {to}\r\nSubject: {subject}\r\n" msg += f"Content-Type: text/plain; charset=UTF-8\r\n" msg += f"Chat-Version: 1.0\r\n" msg += f"Message-ID: <{int(time.time())}@torbot.deltachat>\r\n\r\n{body}" server.sendmail(GMAIL_EMAIL, [to], msg.encode('utf-8')) server.quit() return True except Exception as e: logger.error(f"SMTP Error: {e}") return False def tor_request_bridges(bridge_type): return smtp_send(TOR_EMAIL, "Get bridges", f"get transport {bridge_type}") def parse_obfs4_line(line): if 'obfs4' not in line.lower() or '(Request' in line: return None match = re.search(r'obfs4\s+[\[\]:0-9a-f:\s]+\S+(?:\s+\S+)*', line, re.IGNORECASE) return match.group(0).strip() if match else None def parse_vanilla_line(line): if 'obfs4' in line.lower() or '(Request' in line: return None match = re.search(r'(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}:\d+)\s+([A-Fa-f0-9]{20,})', line) return f"{match.group(1)} {match.group(2)}" if match else None def parse_ipv6_line(line): if 'obfs4' not in line.lower() or '(Request' in line: return None match = re.search(r'obfs4\s+\[[0-9a-f:]+\]:\d+\s+\S+(?:\s+\S+)*', line, re.IGNORECASE) return match.group(0).strip() if match else None def parse_tor_bridges(response, bridge_type): bridges = [] for line in re.split(r'\r\n|\n', response): line = line.strip() if not line or 'Here is your bridge' in line or 'This is an automated' in line or '[This is an email' in line or '(Request' in line: continue parsed = None if bridge_type == 'obfs4': parsed = parse_obfs4_line(line) elif bridge_type == 'vanilla': parsed = parse_vanilla_line(line) elif bridge_type == 'ipv6': parsed = parse_ipv6_line(line) if parsed: bridges.append(parsed) return bridges[:5] def extract_plain_text_from_email(mail, msg_id): import email as email_module try: status, raw_data = mail.fetch(msg_id, '(BODY[])') if status != 'OK': return None raw_msg = raw_data[0][1] if isinstance(raw_data[0], tuple) else raw_data[0] msg = email_module.message_from_bytes(raw_msg) if msg.is_multipart(): for part in msg.walk(): if part.get_content_type() == 'text/plain': payload = part.get_payload(decode=True) charset = part.get_content_charset() or 'utf-8' return payload.decode(charset, errors='replace') else: payload = msg.get_payload(decode=True) charset = msg.get_content_charset() or 'utf-8' return payload.decode(charset, errors='replace') return None except Exception as e: logger.error(f"Error extracting text: {e}") return None def send_bridges_to_user(account, chat_id, bridges, bridge_type, from_cache=False): type_name = {'obfs4': 'obfs4', 'vanilla': 'vanilla', 'ipv6': 'IPv6'}.get(bridge_type, bridge_type) cache_label = ' (кэш)' if from_cache else '' chat = account.get_chat_by_id(chat_id) chat.send_text(f"🌉 Ваши {type_name} мосты{cache_label}:") for bridge in bridges: chat.send_text(bridge) chat.send_text("Скопируйте мост в настройки Tor Browser.\nМосты ограничены по времени, обновляйте при необходимости.") _tor_lock = threading.Lock() def tor_check_and_deliver(account, max_attempts=10, check_interval=30): if not _tor_lock.acquire(blocking=False): logger.info("Tor checker: already running, skipping") return try: pending = load_pending_tor() if not pending: logger.info("Tor checker: No pending requests") return logger.info(f"Tor checker: Starting with {len(pending)} pending requests") current_pending = [p for p in pending if not p.get('delivered')] if not current_pending: return for attempt in range(max_attempts): try: mail = imaplib.IMAP4_SSL(GMAIL_IMAP_HOST, GMAIL_IMAP_PORT) mail.login(GMAIL_EMAIL, GMAIL_APP_PASSWORD) mail.select('INBOX') msg_ids = [] status, unseen = mail.search(None, b'FROM bridges@torproject.org UNSEEN') if status == 'OK' and unseen[0]: msg_ids = [mid.decode('utf-8') if isinstance(mid, bytes) else str(mid) for mid in unseen[0].split()] if not msg_ids: from_date = (datetime.now() - timedelta(minutes=10)).strftime('%d-%b-%Y') search_cmd = f'FROM bridges@torproject.org SINCE {from_date}'.encode('utf-7') status, recent = mail.search(None, search_cmd) if status == 'OK' and recent[0]: msg_ids = [mid.decode('utf-8') if isinstance(mid, bytes) else str(mid) for mid in recent[0].split()] if not msg_ids: mail.logout() time.sleep(check_interval) continue msg_ids = sorted(msg_ids, key=lambda x: int(x), reverse=True) for msg_id in msg_ids: plain_body = extract_plain_text_from_email(mail, msg_id) if not plain_body: continue for pending_req in list(current_pending): if pending_req.get('delivered'): continue bridges = parse_tor_bridges(plain_body, pending_req.get('type', 'obfs4')) if bridges: try: chat = account.get_chat_by_id(pending_req['chat_id']) chat.send_text(f"🌉 Ваши {pending_req.get('type', 'obfs4')} мосты:") for bridge in bridges: chat.send_text(bridge) chat.send_text("Скопируйте мост в настройки Tor Browser.") cache = load_bridges_cache() cache[pending_req.get('type', 'obfs4')] = {'bridges': bridges, 'timestamp': int(time.time())} save_bridges_cache(cache) except Exception as e: logger.error(f"Tor: Error: {e}") pending_req['delivered'] = True remaining = [p for p in current_pending if not p.get('delivered')] save_pending_tor(remaining) logger.info(f"Tor: delivered to chat {pending_req['chat_id']}, {len(remaining)} remaining") else: logger.info(f"Tor: No {pending_req.get('type')} bridges found in email {msg_id}") mail.store(msg_id, '+FLAGS', '\\SEEN') mail.logout() if not any(not p.get('delivered') for p in current_pending): save_pending_tor([]) return except Exception as e: logger.error(f"Tor checker: IMAP error: {e}") try: mail.logout() except: pass if attempt < max_attempts - 1: time.sleep(check_interval) for pending_req in current_pending: if not pending_req.get('delivered'): cached = load_bridges_cache().get(pending_req.get('type', 'obfs4'), {}).get('bridges') if cached: send_bridges_to_user(account, pending_req['chat_id'], cached, pending_req.get('type', 'obfs4'), from_cache=True) pending_req['delivered'] = True else: chat = account.get_chat_by_id(pending_req['chat_id']) chat.send_text("К сожалению, не удалось получить мосты. Попробуйте позже.") save_pending_tor([]) finally: _tor_lock.release() def get_cached_bridges(bridge_type): cached = load_bridges_cache().get(bridge_type) if cached and time.time() - cached.get('timestamp', 0) < BRIDGES_CACHE_TTL: return cached.get('bridges') return None # --- Notes --- def load_allowed_contacts(): return set(load_json(ALLOWED_CONTACTS_FILE, [])) def save_allowed_contacts(contacts): save_json(ALLOWED_CONTACTS_FILE, list(contacts)) TRUSTED_DOMAINS = {"budaev.org", "dc.budaev.org"} def _matches_allowlist(addr, allowed): """Return True if addr matches any entry (exact email or *@domain wildcard).""" if addr in allowed: return True domain = addr.split("@")[-1] if "@" in addr else "" return domain in TRUSTED_DOMAINS or f"*@{domain}" in allowed def is_contact_allowed(account, from_id): """Return True if the sender is verified via SecureJoin or in the explicit allowlist.""" if from_id <= 0: return False try: contact_data = account._rpc.get_contact(account.id, from_id) if contact_data.get('isVerified', False): return True addr = contact_data.get('address', '').lower() return _matches_allowlist(addr, load_allowed_contacts()) except Exception as e: logger.warning(f"is_contact_allowed error for contact {from_id}: {e}") return False def load_notes(): return load_json(NOTES_FILE, {}) def save_notes(data): save_json(NOTES_FILE, data) def get_notes(chat_id): return load_notes().get(str(chat_id), []) def add_note(chat_id, text): data = load_notes() key = str(chat_id) notes = data.get(key, []) next_id = max((n["id"] for n in notes), default=0) + 1 notes.append({"id": next_id, "text": text, "created": datetime.now(IRKUTSK_TZ).isoformat()}) data[key] = notes save_notes(data) return next_id def delete_note(chat_id, note_id): data = load_notes() key = str(chat_id) notes = data.get(key, []) new_notes = [n for n in notes if n["id"] != note_id] if len(new_notes) == len(notes): return False data[key] = new_notes save_notes(data) return True # --- Exchange rates --- def get_exchange_rates(): now = time.time() if now - _rates_cache["ts"] < 3600 and _rates_cache["data"]: return _rates_cache["data"] try: r = requests.get(CBR_RATES_URL, timeout=10) r.raise_for_status() root = ET.fromstring(r.content) rates = {} for valute in root.findall("Valute"): code = valute.findtext("CharCode", "") name = valute.findtext("Name", "") nominal_str = valute.findtext("Nominal", "1").replace(",", ".") value_str = valute.findtext("Value", "0").replace(",", ".") try: rates[code] = (int(nominal_str), float(value_str), name) except ValueError: pass _rates_cache["ts"] = now _rates_cache["data"] = rates return rates except Exception as e: logger.error(f"CBR rates error: {e}") raise # --- Network utilities --- def lookup_ip_info(ip_or_host): try: logger.info(f"=== lookup_ip_info: {ip_or_host}") r = requests.get(f"https://ipinfo.io/{ip_or_host}/json", timeout=10) r.raise_for_status() data = r.json() logger.info(f"=== lookup_ip_info result: {data.get('ip', '?')}") return data except Exception as e: logger.warning(f"=== lookup_ip_info failed: {e}") raise RuntimeError(f"ipinfo.io: {e}") def dns_lookup(domain): try: infos = socket.getaddrinfo(domain, None) addrs = list(dict.fromkeys(info[4][0] for info in infos)) return addrs except socket.gaierror as e: raise RuntimeError(f"DNS: {e}") # --- Site monitors --- def load_monitors(): return load_json(MONITORS_FILE, []) def save_monitors(data): save_json(MONITORS_FILE, data) def check_url(url): try: r = requests.get(url, timeout=MONITOR_TIMEOUT, allow_redirects=True) return True, str(r.status_code) except requests.Timeout: return False, "Timeout" except requests.ConnectionError as e: return False, str(e)[:80] except Exception as e: return False, str(e)[:80] def monitor_worker(account): while True: time.sleep(MONITOR_INTERVAL) monitors = load_monitors() changed = False for m in monitors: ok, detail = check_url(m["url"]) was_ok = m.get("last_ok") m["last_check"] = time.time() if was_ok is not None and ok != was_ok: if ok: alert = f"Сайт восстановлен\n{m['url']}" else: alert = f"САЙТ НЕДОСТУПЕН\n{m['url']}\nСтатус: {detail}" chat_id = m.get("added_chat") if chat_id: try: account._rpc.send_msg(account.id, chat_id, {"text": alert}) except Exception as e: logger.error(f"Monitor alert error: {e}") logger.info(f"Monitor: {m['url']} {'UP' if ok else 'DOWN'} ({detail})") m["last_ok"] = ok m["last_detail"] = detail changed = True if changed: save_monitors(monitors) # --- CalDAV reminders --- def load_cal_reminders_sent(): return load_json(CAL_REMINDERS_FILE, {}) def save_cal_reminders_sent(data): save_json(CAL_REMINDERS_FILE, data) def cal_reminder_worker(account): while True: time.sleep(CAL_REMINDER_INTERVAL) try: now = datetime.now(timezone.utc) window_end = now + timedelta(seconds=CAL_REMINDER_AHEAD) evs = cal_list_events(days_ahead=1) sent = load_cal_reminders_sent() # Чистим старые записи (старше 25 часов) cutoff = now.timestamp() - 25 * 3600 sent = {uid: ts for uid, ts in sent.items() if ts > cutoff} subscribers = load_subscribers() for ev in evs: uid = ev.get("uid") dtstart = ev.get("dtstart_dt") if not uid or not dtstart or uid in sent: continue if now <= dtstart <= window_end: local_dt = dtstart.astimezone(timezone(IRKUTSK_OFFSET)) title = ev.get("summary", "(без названия)") dtend = ev.get("dtend_dt") if dtend: local_end = dtend.astimezone(timezone(IRKUTSK_OFFSET)) time_range = f"{local_dt.strftime('%H:%M')}–{local_end.strftime('%H:%M')}" else: time_range = local_dt.strftime("%H:%M") text = f"Напоминание: через ~15 мин\n{time_range} — {title}" for accid, chat_id in list(subscribers): try: account._rpc.send_msg(account.id, chat_id, {"text": text}) except Exception as e: logger.error(f"CalDAV reminder send error: {e}") sent[uid] = now.timestamp() logger.info(f"CalDAV reminder sent: {title}") save_cal_reminders_sent(sent) except Exception as e: logger.warning(f"cal_reminder_worker error: {e}") # --- Whisper transcription --- def transcribe_audio(file_path): account_id = os.environ.get("CLOUDFLARE_ACCOUNT_ID") api_token = os.environ.get("CLOUDFLARE_API_TOKEN") if not account_id or not api_token: return None url = f"https://api.cloudflare.com/client/v4/accounts/{account_id}/ai/run/@cf/openai/whisper" try: with open(file_path, "rb") as f: audio_data = f.read() r = requests.post( url, headers={"Authorization": f"Bearer {api_token}"}, data=audio_data, timeout=60, ) if r.status_code != 200: logger.warning(f"Whisper API error: {r.status_code} {r.text[:200]}") return None data = r.json() if not data.get("success"): return None return data.get("result", {}).get("text") or data.get("result", {}).get("transcription") except Exception as e: logger.warning(f"transcribe_audio error: {e}") return None IRKUTSK_OFFSET = timedelta(hours=8) def _caldav_auth(): return (CALDAV_USER, CALDAV_PASSWORD) def _caldav_headers(extra=None): h = {"Content-Type": "application/xml; charset=utf-8"} if extra: h.update(extra) return h def cal_list_events(days_ahead=1, max_events=20): """Запрашивает события из CalDAV за указанный диапазон дней.""" now = datetime.now(timezone.utc) end = now + timedelta(days=days_ahead) dtstart = now.strftime("%Y%m%dT%H%M%SZ") dtend = end.strftime("%Y%m%dT%H%M%SZ") body = f""" """ try: r = requests.request( "REPORT", CALDAV_URL, auth=_caldav_auth(), headers={"Content-Type": "application/xml; charset=utf-8", "Depth": "1"}, data=body.encode("utf-8"), timeout=10, ) r.raise_for_status() return _parse_caldav_response(r.text) except Exception as e: logger.error(f"CalDAV list error: {e}") raise def cal_add_event(title, dt_start, dt_end=None, description=""): """Создаёт событие через CalDAV PUT.""" if dt_end is None: dt_end = dt_start + timedelta(hours=1) uid = str(uuid.uuid4()) fmt = "%Y%m%dT%H%M%S" # Используем локальное время Иркутска с явным смещением tz_suffix = "+0800" ical = ( "BEGIN:VCALENDAR\r\n" "VERSION:2.0\r\n" "PRODID:-//DeltaBot//CalDAV//EN\r\n" "BEGIN:VEVENT\r\n" f"UID:{uid}\r\n" f"DTSTART;TZID=Asia/Irkutsk:{dt_start.strftime(fmt)}\r\n" f"DTEND;TZID=Asia/Irkutsk:{dt_end.strftime(fmt)}\r\n" f"SUMMARY:{title}\r\n" ) if description: ical += f"DESCRIPTION:{description}\r\n" ical += ( "BEGIN:VALARM\r\n" "ACTION:DISPLAY\r\n" "DESCRIPTION:Напоминание\r\n" "TRIGGER:-PT15M\r\n" "END:VALARM\r\n" "END:VEVENT\r\nEND:VCALENDAR\r\n" ) url = CALDAV_URL.rstrip("/") + f"/{uid}.ics" try: r = requests.put( url, auth=_caldav_auth(), headers={"Content-Type": "text/calendar; charset=utf-8"}, data=ical.encode("utf-8"), timeout=10, ) r.raise_for_status() return uid except Exception as e: logger.error(f"CalDAV add error: {e}") raise def cal_delete_event(uid): """Удаляет событие по UID.""" url = CALDAV_URL.rstrip("/") + f"/{uid}.ics" try: r = requests.delete(url, auth=_caldav_auth(), timeout=10) if r.status_code == 404: raise ValueError("Событие не найдено") r.raise_for_status() except ValueError: raise except Exception as e: logger.error(f"CalDAV delete error: {e}") raise def _parse_caldav_response(xml_text): """Парсит multistatus XML и извлекает список событий.""" events_list = [] try: root = ET.fromstring(xml_text) ns = { "D": "DAV:", "C": "urn:ietf:params:xml:ns:caldav", } for resp in root.findall(".//D:response", ns): cal_data_el = resp.find(".//C:calendar-data", ns) if cal_data_el is None or not cal_data_el.text: continue ev = _parse_ical(cal_data_el.text) if ev: events_list.append(ev) except ET.ParseError as e: logger.error(f"CalDAV XML parse error: {e}") events_list.sort(key=lambda x: x.get("dtstart_dt") or datetime.min.replace(tzinfo=timezone.utc)) return events_list def _parse_ical(ical_text): """Извлекает поля VEVENT из iCalendar-текста.""" ev = {} in_vevent = False for raw_line in ical_text.splitlines(): line = raw_line.strip() if line == "BEGIN:VEVENT": in_vevent = True ev = {} continue if line == "END:VEVENT": in_vevent = False continue if not in_vevent: continue if ":" not in line: continue key_part, _, value = line.partition(":") key = key_part.split(";")[0].upper() if key == "UID": ev["uid"] = value elif key == "SUMMARY": ev["summary"] = value elif key == "DESCRIPTION": ev["description"] = value elif key in ("DTSTART", "DTEND"): dt = _parse_ical_dt(value) field = "dtstart_dt" if key == "DTSTART" else "dtend_dt" if dt: ev[field] = dt return ev if ev.get("uid") else None def _parse_ical_dt(value): """Разбирает дату/время из iCalendar.""" value = value.strip() try: if value.endswith("Z"): dt = datetime.strptime(value, "%Y%m%dT%H%M%SZ").replace(tzinfo=timezone.utc) elif "T" in value: dt = datetime.strptime(value, "%Y%m%dT%H%M%S").replace(tzinfo=timezone(IRKUTSK_OFFSET)) else: dt = datetime.strptime(value, "%Y%m%d").replace(tzinfo=timezone(IRKUTSK_OFFSET)) return dt except ValueError: return None def _format_event(ev, show_uid=False): """Форматирует событие для отображения.""" dt = ev.get("dtstart_dt") dt_end = ev.get("dtend_dt") if dt: local_dt = dt.astimezone(timezone(IRKUTSK_OFFSET)) date_str = local_dt.strftime("%d.%m %H:%M") if dt_end: local_end = dt_end.astimezone(timezone(IRKUTSK_OFFSET)) if local_dt.date() == local_end.date(): date_str += f"–{local_end.strftime('%H:%M')}" else: date_str = "?" title = ev.get("summary", "(без названия)") desc = ev.get("description", "") line = f"{date_str} — {title}" if desc: line += f"\n {desc}" if show_uid: uid = ev.get("uid", "") line += f"\n ID: {uid[:8]}" return line RU_MONTHS = { "января": 1, "февраля": 2, "марта": 3, "апреля": 4, "мая": 5, "июня": 6, "июля": 7, "августа": 8, "сентября": 9, "октября": 10, "ноября": 11, "декабря": 12, "january": 1, "february": 2, "march": 3, "april": 4, "may": 5, "june": 6, "july": 7, "august": 8, "september": 9, "october": 10, "november": 11, "december": 12, } def _parse_cal_datetime(date_str, time_str): """Парсит дату/время для команды /cal add в часовом поясе Иркутска.""" now_irkutsk = datetime.now(timezone(IRKUTSK_OFFSET)) date_str = date_str.strip() time_str = time_str.strip() if date_str.lower() in ("сегодня", "today"): d = now_irkutsk.date() elif date_str.lower() in ("завтра", "tomorrow"): d = (now_irkutsk + timedelta(days=1)).date() else: # "22 июня" / "22 июня 2026" words = date_str.split() if len(words) >= 2 and words[1].lower() in RU_MONTHS: try: month = RU_MONTHS[words[1].lower()] day = int(words[0]) year = int(words[2]) if len(words) >= 3 else now_irkutsk.year from datetime import date as _date d = _date(year, month, day) except (ValueError, IndexError): raise ValueError(f"Неверная дата: {date_str}") else: for fmt in ("%d.%m.%Y", "%d.%m"): try: parsed = datetime.strptime(date_str, fmt) d = parsed.replace(year=now_irkutsk.year).date() if fmt == "%d.%m" else parsed.date() break except ValueError: continue else: raise ValueError(f"Неверный формат даты: {date_str}") try: t = datetime.strptime(time_str, "%H:%M").time() except ValueError: raise ValueError(f"Неверный формат времени: {time_str}") return datetime.combine(d, t) hooks = events.HookCollection() @hooks.on(events.RawEvent) def log_event(event): if event.kind == EventType.INFO: logger.info(event.msg) elif event.kind == EventType.WARNING: logger.warning(event.msg) elif event.kind == EventType.ERROR: logger.error(event.msg) @hooks.on(events.NewMessage(is_info=False)) def handle_message(event): message = event.message_snapshot text = message.text.strip() if message.text else "" chat_id = message.chat_id from_id = getattr(message, 'from_id', 0) account = message.chat.account if not is_contact_allowed(account, from_id): logger.info(f"Ignoring message from unverified contact id={from_id} in chat {chat_id}") return # Транскрипция голосовых/аудио сообщений через Cloudflare Whisper file_path = getattr(message, 'file', None) file_mime = getattr(message, 'file_mime_type', None) or "" if file_path and file_mime.startswith("audio/") and not text: transcript = transcribe_audio(file_path) if transcript: message.chat.send_text(f"Транскрипция:\n{transcript}") return else: message.chat.send_text("Не удалось транскрибировать аудио.") return if text and not text.startswith("/"): cmd = parse_natural_language(text) if cmd: text = cmd if text.startswith("/weather"): raw = text[8:].strip() city = DEFAULT_CITY days = 3 raw_lower = raw.lower() if 'на неделю' in raw_lower or ' неделя' in raw_lower or raw_lower.strip() == 'week': days = 7 raw = _remove_day_keyword(raw) elif 'на 10 дней' in raw_lower or 'десять дней' in raw_lower: days = 10 raw = _remove_day_keyword(raw) days_map = {'7': 7, '10': 10} for word in raw.split(): if word in days_map: days = days_map[word] raw = raw.replace(word, '').strip() city = raw.strip() if raw.strip() else DEFAULT_CITY message.chat.send_text(get_weather(city, forecast_days=days)) elif text == "/status": message.chat.send_text(get_system_status()) elif text.startswith("/bridges"): parts = text.split(maxsplit=1) bridge_type = parts[1].strip().lower() if len(parts) > 1 else 'obfs4' valid_types = ['obfs4', 'vanilla', 'ipv6'] if bridge_type not in valid_types: message.chat.send_text(f"Неизвестный тип моста. Доступны: {', '.join(valid_types)}\nПример: /bridges obfs4") return cached = get_cached_bridges(bridge_type) if cached: send_bridges_to_user(message.chat.account, chat_id, cached, bridge_type, from_cache=True) return if tor_request_bridges(bridge_type): pending = load_pending_tor() pending.append({"chat_id": chat_id, "type": bridge_type, "timestamp": int(time.time())}) save_pending_tor(pending) account = message.chat.account threading.Thread(target=tor_check_and_deliver, args=(account,), daemon=True).start() message.chat.send_text("Запрос отправлен. Ожидайте ответ в течение нескольких минут...") else: message.chat.send_text("Ошибка отправки запроса. Попробуйте позже.") elif text == "/subscribe": account = message.chat.account subscribers = load_subscribers() subscribers.add((account.id, chat_id)) save_subscribers(subscribers) message.chat.send_text("Вы подписаны на ежедневный отчет (09:00 по Иркутску, сервер + погода).") elif text == "/unsubscribe": account = message.chat.account subscribers = load_subscribers() subscribers.discard((account.id, chat_id)) save_subscribers(subscribers) message.chat.send_text("Вы отписаны от ежедневного статуса.") elif text.startswith("/channels"): parts = text.split(maxsplit=1) subcmd = parts[1].strip().lower() if len(parts) > 1 else "" channels = load_telegram_channels() account = message.chat.account if subcmd == "list" or subcmd == "": if not channels: message.chat.send_text("📺 Список каналов пуст.\n\n/channels add username — добавить канал") else: msg_text = "📺 Каналы Telegram:\n\n" for ch in channels: title = ch.get('title', ch['username']) invite_link = ch.get('invite_link', '') if invite_link: msg_text += f"📡 {title}\n🔗 {invite_link}\n📝 Ретрансляция @{ch['username']}\n" else: msg_text += f"📡 {title} (@{ch['username']})\n" msg_text += "───────────────────────\n\n" msg_text += "/channels add username — добавить канал" message.chat.send_text(msg_text) elif subcmd.startswith("add "): username = subcmd[4:].strip().lstrip('@') if not username: message.chat.send_text("Укажите username: /channels add username") return if any(ch['username'] == username for ch in channels): message.chat.send_text(f"@{username} уже есть в списке.\n/channels invite {username} — отправить QR повторно") return message.chat.send_text(f"⏳ Создаю канал @{username}...") logger.info(f"Creating channel: {username}") posts, channel_title, channel_desc, channel_img, error = get_telegram_feed(username, 1) if isinstance(channel_title, dict): channel_title = channel_title.get('#text', '') or str(channel_title) if isinstance(channel_desc, dict): channel_desc = channel_desc.get('#text', '') or str(channel_desc) if isinstance(channel_img, dict): channel_img = channel_img.get('url', '') or str(channel_img) channel_title = str(channel_title) if channel_title else username channel_desc = str(channel_desc) if channel_desc else '' channel_img = str(channel_img) if channel_img else '' debug_info = f"RSS: title='{channel_title}', desc='{channel_desc[:50] if channel_desc else 'empty'}', img='{str(channel_img)[:100]}', error='{error}'" logger.info(debug_info) message.chat.send_text(f"📋 {debug_info}") if error: message.chat.send_text(f"❌ Канал @{username} недоступен. Проверьте имя канала.") return if not posts and not channel_title: message.chat.send_text(f"❌ Канал @{username} не найден или недоступен.") return title = channel_title if channel_title else username description = channel_desc if channel_desc else f"Ретрансляция канала @{username}" try: logger.info(f"Creating broadcast: {title}") chat = account.create_broadcast(title) chat.set_name(title) logger.info(f"Channel {username}: title={title}, description={channel_desc[:50] if channel_desc else 'empty'}, image={channel_img[:50] if channel_img else 'empty'}") if not channel_img: logger.warning(f"No image URL in RSS for {username}") try: chat._rpc.set_chat_description(account.id, chat.id, description) logger.info(f"Description set: {description[:100]}") except Exception as desc_e: logger.error(f"Failed to set description: {desc_e}") if channel_img: try: logger.info(f"Downloading channel image: {channel_img}") img_resp = requests.get(channel_img, timeout=15, headers={'User-Agent': 'Mozilla/5.0'}) if img_resp.status_code == 200: img_path = os.path.join(CONFIG_DIR, f"channel_img_{chat.id}.jpg") with open(img_path, 'wb') as f: f.write(img_resp.content) logger.info(f"Image saved to {img_path}, size={len(img_resp.content)}") chat.set_image(img_path) logger.info(f"Set channel image from {channel_img}") else: logger.warning(f"Image download failed: {img_resp.status_code}") except Exception as img_e: logger.error(f"Could not set channel image: {img_e}") broadcast_chat_id = chat.id logger.info(f"Broadcast channel created: {title} with chat_id={broadcast_chat_id}") invite_link = None try: qr = chat.get_qr_code() if isinstance(qr, str): invite_link = qr elif isinstance(qr, dict): invite_link = qr.get('qr') or qr.get('url') or qr.get('link') logger.info(f"QR code obtained: {invite_link}") except Exception as qr_e: logger.warning(f"Could not get QR code: {qr_e}") new_channel = { 'username': username, 'title': title, 'description': description, 'image_url': channel_img or '', 'broadcast_chat_id': broadcast_chat_id, 'invite_link': invite_link or '', 'last_post_id': '' } channels.append(new_channel) save_telegram_channels(channels) if invite_link: message.chat.send_text(f"✅ Канал '{title}' создан!\n\n🔗 Подписка: {invite_link}") else: message.chat.send_text(f"✅ Канал '{title}' создан!\n⚠️ QR код недоступен, используйте /channels invite позже") chat.send_text(f"📺 Тест канала '{title}'\n\nЭто тестовое сообщение. Подпишитесь на канал через ссылку выше.") except Exception as e: logger.error(f"Error creating channel: {e}") message.chat.send_text(f"❌ Ошибка создания канала: {e}") elif subcmd.startswith("invite "): identifier = subcmd[7:].strip().lstrip('@') ch = next((c for c in channels if c['username'] == identifier), None) if not ch: message.chat.send_text(f"Канал @{identifier} не найден.") return message.chat.send_text(f"⏳ Получаю QR для {ch['title']}...") try: chat = account.get_chat_by_id(ch['broadcast_chat_id']) qr = chat.get_qr_code() if isinstance(qr, str): invite_link = qr elif isinstance(qr, dict): invite_link = qr.get('qr') or qr.get('url') or qr.get('link') if invite_link: ch['invite_link'] = invite_link save_telegram_channels(channels) message.chat.send_text(f"✅ QR для '{ch['title']}':\n\n🔗 {invite_link}") else: message.chat.send_text(f"⚠️ Не удалось получить QR код") except Exception as e: logger.error(f"Error getting invite: {e}") message.chat.send_text(f"❌ Ошибка: {e}") elif subcmd.startswith("remove "): username = subcmd[7:].strip().lstrip('@') ch = next((c for c in channels if c['username'] == username), None) if not ch: message.chat.send_text(f"@{username} не найден в списке.") return channels = [c for c in channels if c['username'] != username] save_telegram_channels(channels) message.chat.send_text(f"✅ @{username} удалён из списка.\n(пушить перестаём, канал Delta Chat остаётся)") elif subcmd.startswith("description "): identifier = subcmd[12:].strip().lstrip('@') ch = next((c for c in channels if c['username'] == identifier), None) if not ch: message.chat.send_text(f"Канал @{identifier} не найден.") return set_pending_channel(chat_id, identifier, ch['broadcast_chat_id'], "description") message.chat.send_text(f"✏️ Введите описание для канала @{identifier}:") elif subcmd.startswith("image "): identifier = subcmd[6:].strip().lstrip('@') ch = next((c for c in channels if c['username'] == identifier), None) if not ch: message.chat.send_text(f"Канал @{identifier} не найден.") return set_pending_channel(chat_id, identifier, ch['broadcast_chat_id'], "image") message.chat.send_text(f"🖼 Отправьте изображение для канала @{identifier}:") else: message.chat.send_text("Команды /channels:\n/list — список каналов\n/add username — создать broadcast канал\n/invite username — QR для подписки\n/description username — изменить описание\n/image username — изменить изображение\n/remove username — удалить из списка") elif text.startswith("/telegram"): parts = text.split(maxsplit=2) if len(parts) < 2: message.chat.send_text("Использование: /telegram username [N]\n/telegram markettwits 10\nМаксимум: 100") return username = parts[1].strip().lstrip('@') limit = 10 if len(parts) > 2: try: limit = min(int(parts[2]), 100) except: pass posts, channel_title, _, _, error = get_telegram_feed(username, limit) if error: message.chat.send_text(f"❌ {error}") return if not posts: message.chat.send_text("Посты не найдены.") return display_name = channel_title or username msg_text = f"📺 {display_name}\nПоследние {len(posts)} постов:\n\n" for i, post in enumerate(posts, 1): text = re.sub(r'<[^>]+>', '', (post.get('description') or post.get('title', '')))[:200] link = post.get('link', '') msg_text += f"{i}. {text}\n{link}\n\n" message.chat.send_text(msg_text) elif text.startswith("/save") or text.startswith("/s "): logger.info(f"/save command received, text='{text}', file={message.file}") if get_pending_save(chat_id): message.chat.send_text("Дождитесь ответа на предыдущий запрос.") return parts = text.split(maxsplit=1) save_path = parts[1].strip() if len(parts) > 1 else "" file_blob = message.file logger.info(f"file_blob: {file_blob}") if not file_blob: message.chat.send_text("Прикрепите файл и укажите путь: /save /путь/к/файлу") return if not save_path: message.chat.send_text("Укажите путь для сохранения: /save /путь/к/файлу") return if ".." in save_path: message.chat.send_text("Путь не должен содержать ..") return abs_path = os.path.abspath(os.path.expanduser(save_path)) if save_path.endswith('/'): message.chat.send_text("Укажите имя файла: /save /путь/к/файлу") return if os.path.isdir(abs_path): file_name = getattr(message, 'filename', None) or 'file' abs_path = os.path.join(abs_path, file_name) allowed = False for prefix in DEFAULT_SAVE_DIRS + [BOT_DIR, SAVE_DIR]: if abs_path.startswith(os.path.abspath(prefix)): allowed = True break if not allowed: message.chat.send_text(f"Недопустимый путь. Разрешены: /tmp/, ~/") return file_blob_clean = file_blob.replace("accounts/", "", 1) if file_blob.startswith("accounts/") else file_blob file_path = os.path.realpath(os.path.join(ACCOUNTS_DIR, file_blob_clean)) logger.info(f"file_path: {file_path}") if not file_path.startswith(os.path.realpath(ACCOUNTS_DIR) + os.sep): message.chat.send_text("Недопустимый путь файла.") return if not os.path.exists(file_path): message.chat.send_text("Не удалось найти файл.") return MAX_SAVE_SIZE = 50 * 1024 * 1024 # 50 MB if os.path.getsize(file_path) > MAX_SAVE_SIZE: message.chat.send_text("Файл слишком большой (макс. 50 МБ).") return with open(file_path, 'rb') as f: file_bytes = f.read() logger.info(f"Read {len(file_bytes)} bytes from file") file_name = os.path.basename(abs_path) exists = os.path.exists(abs_path) if exists: msg_text = f"⚠️ Файл {file_name} уже существует.\nЗаменить? [Да / Нет]" else: msg_text = f"📄 Сохранить файл {file_name}?\nПуть: {abs_path}\n[Да / Нет]" message.chat.send_text(msg_text) set_pending_save(chat_id, file_name, abs_path, base64.b64encode(file_bytes).decode()) elif get_pending_channel(chat_id): pc = get_pending_channel(chat_id) account = message.chat.account cancel_words = ['отмена', 'cancel', 'нет', 'no', 'n'] try: chat = account.get_chat_by_id(pc['broadcast_chat_id']) except: message.chat.send_text("Ошибка: канал не найден.") clear_pending_channel(chat_id) return if text.lower() in cancel_words: clear_pending_channel(chat_id) message.chat.send_text("Операция отменена.") return if pc['action'] == "description": if not text: message.chat.send_text("Введите текст описания.") return try: chat._rpc.set_chat_description(account.id, chat.id, text) channels = load_telegram_channels() for ch in channels: if ch['username'] == pc['username']: ch['description'] = text break save_telegram_channels(channels) message.chat.send_text(f"✅ Описание канала @{pc['username']} обновлено.") except Exception as e: message.chat.send_text(f"❌ Ошибка: {e}") clear_pending_channel(chat_id) return elif pc['action'] == "image": file_path = message.file if not file_path: message.chat.send_text("Прикрепите изображение к сообщению.") return try: clean = file_path.replace("accounts/", "", 1) if file_path.startswith("accounts/") else file_path abs_path = os.path.realpath(os.path.join(ACCOUNTS_DIR, clean)) if not abs_path.startswith(os.path.realpath(ACCOUNTS_DIR) + os.sep): message.chat.send_text("Недопустимый путь файла.") clear_pending_channel(chat_id) return if not os.path.exists(abs_path): message.chat.send_text("Не удалось найти файл изображения.") clear_pending_channel(chat_id) return chat.set_image(abs_path) message.chat.send_text(f"✅ Изображение канала @{pc['username']} обновлено.") except Exception as e: message.chat.send_text(f"❌ Ошибка: {e}") clear_pending_channel(chat_id) return elif get_pending_save(chat_id): pending = get_pending_save(chat_id) logger.info(f"Pending save for chat_id={chat_id}: {pending is not None}") if not pending: message.chat.send_text("Нет ожидающего сохранения.") return path = pending['path'] base64_data = pending.get('base64', '') if text.lower() in ['да', 'yes', 'y', 'а', 'lf']: try: file_bytes = base64.b64decode(base64_data) os.makedirs(os.path.dirname(path), exist_ok=True) if os.path.exists(path): os.rename(path, path + ".backup") with open(path, 'wb') as f: f.write(file_bytes) message.chat.send_text(f"✅ Файл сохранён: {path}") if path.endswith('.py'): message.chat.send_text("⚠️ Для активации: systemctl restart deltabot") clear_pending_save(chat_id) except Exception as e: message.chat.send_text(f"Ошибка сохранения: {e}") clear_pending_save(chat_id) elif text.lower() in ['нет', 'no', 'n', 'ytn']: message.chat.send_text("Сохранение отменено.") clear_pending_save(chat_id) else: message.chat.send_text("Ответьте [Да] или [Нет]") elif text.startswith("/join"): sub = text.split(maxsplit=1) if len(sub) < 2: message.chat.send_text("Использование: /join https://i.delta.chat/#...") return value = sub[1].strip() account = message.chat.account if re.match(r'https://i\.delta\.chat/#', value): message.chat.send_text("⏳ Запускаю secure join...") threading.Thread(target=_process_secure_join, args=(account, message.chat, value), daemon=True).start() return message.chat.send_text("Некорректная ссылка. Используй /join https://i.delta.chat/#...") elif text == "/qr": try: account = message.chat.account qr = account.get_qr_code() logger.info(f"Account QR code: {qr}") message.chat.send_text(f"🔗 QR код для добавления бота:\n\n{qr}") except Exception as e: logger.error(f"Error getting QR: {e}") message.chat.send_text(f"❌ Ошибка: {e}") elif text == "/help": message.chat.send_text(HELP_TEXT) elif text.startswith("/addcontact"): parts = text.split(maxsplit=1) email = parts[1].strip().lower() if len(parts) > 1 else "" if not email: message.chat.send_text("Использование: /addcontact email@example.com или *@domain.org") elif "@" not in email or "." not in email.split("@")[-1]: message.chat.send_text("Неверный формат. Примеры: user@example.com или *@example.com") else: try: sender_data = account._rpc.get_contact(account.id, from_id) if not sender_data.get('isVerified', False): message.chat.send_text("Только верифицированные контакты (SecureJoin) могут добавлять новых.") else: allowed = load_allowed_contacts() allowed.add(email) save_allowed_contacts(allowed) message.chat.send_text(f"Контакт {email} добавлен в белый список.") except Exception as e: message.chat.send_text(f"Ошибка: {e}") elif text.startswith("/contacts"): parts = text.split(maxsplit=1) subcmd = parts[1].strip().lower() if len(parts) > 1 else "list" if subcmd == "list": allowed = load_allowed_contacts() if not allowed: message.chat.send_text("Белый список пуст. Доступ только через SecureJoin/QR.") else: message.chat.send_text("Разрешённые контакты:\n" + "\n".join(sorted(allowed))) elif subcmd.startswith("remove "): email = subcmd[7:].strip().lower() try: sender_data = account._rpc.get_contact(account.id, from_id) if not sender_data.get('isVerified', False): message.chat.send_text("Только верифицированные контакты могут удалять из списка.") else: allowed = load_allowed_contacts() if email in allowed: allowed.discard(email) save_allowed_contacts(allowed) message.chat.send_text(f"Контакт {email} удалён из белого списка.") else: message.chat.send_text(f"{email} не найден в белом списке.") except Exception as e: message.chat.send_text(f"Ошибка: {e}") else: message.chat.send_text("/contacts list — список\n/contacts remove — удалить") elif text.startswith("/ai"): parts = text.split(maxsplit=1) subcmd = parts[1].strip().lower() if len(parts) > 1 else "" if subcmd == "on": session = ai_agent.load_session(str(chat_id)) session["enabled"] = True ai_agent.save_session(str(chat_id), session) message.chat.send_text(f"🤖 AI включён. Модель: {session.get('model', ai_agent.DEFAULT_MODEL)}\n\n" "Любое сообщение → AI ответ.\n" "/ai off — выключить | /model — сменить модель") elif subcmd == "off": session = ai_agent.load_session(str(chat_id)) session["enabled"] = False ai_agent.save_session(str(chat_id), session) message.chat.send_text("🤖 AI выключен.") elif subcmd == "reset": session = ai_agent.load_session(str(chat_id)) session["messages"] = [] ai_agent.save_session(str(chat_id), session) message.chat.send_text("🤖 История очищена.") elif subcmd == "status": session = ai_agent.load_session(str(chat_id)) enabled = "✅" if session.get("enabled") else "❌" model = session.get("model", ai_agent.DEFAULT_MODEL) msgs = len(session.get("messages", [])) has_key = "✅" if ai_agent.get_api_key(str(chat_id)) else "❌" message.chat.send_text(f"🤖 {enabled} Модель: {model}\nСообщений: {msgs} | Ключ: {has_key}") elif subcmd == "summary": try: result = ai_agent.summarize_session(str(chat_id)) message.chat.send_text(strip_markdown(result)) except Exception as e: message.chat.send_text(f"Ошибка: {e}") else: session = ai_agent.load_session(str(chat_id)) enabled = "✅" if session.get("enabled") else "❌" model = session.get("model", ai_agent.DEFAULT_MODEL) message.chat.send_text(f"🤖 /ai on — включить ({enabled})\n" f"/ai off — выключить\n/model — сменить модель ({model})\n/apikey — ключ") elif text.startswith("/model"): parts = text.split(maxsplit=1) model_arg = parts[1].strip().lower() if len(parts) > 1 else "" if not model_arg: models_list = "\n".join([f"• `{k}` — {v}" for k, v in ai_agent.MODELS.items()]) session = ai_agent.load_session(str(chat_id)) current = session.get("model", ai_agent.DEFAULT_MODEL) message.chat.send_text(f"🤖 Текущая: {current}\n\n{models_list}\n\n/model deepseek — выбрать") else: matched = None for model_name in ai_agent.MODELS: if model_arg in model_name.lower(): matched = model_name break if matched: session = ai_agent.load_session(str(chat_id)) session["model"] = matched ai_agent.save_session(str(chat_id), session) message.chat.send_text(f"🤖 → {matched}") else: message.chat.send_text(f"❌ Не найдена '{model_arg}'. /model — список") elif text.startswith("/apikey"): parts = text.split(maxsplit=1) key = parts[1].strip() if len(parts) > 1 else "" if not key: has_key = "✅" if ai_agent.get_api_key(str(chat_id)) else "❌" message.chat.send_text(f"🔑 Ключ: {has_key}\n/apikey [ключ] — установить (sk-or-...)") else: session = ai_agent.load_session(str(chat_id)) session["api_key"] = key ai_agent.save_session(str(chat_id), session) message.chat.send_text(f"🔑 Установлен ({key[:8]}...{key[-4:]})") elif text.startswith("/note"): parts = text.split(maxsplit=1) subcmd = parts[1].strip() if len(parts) > 1 else "" if subcmd.lower().startswith("add "): note_text = subcmd[4:].strip() if note_text: nid = add_note(chat_id, note_text) message.chat.send_text(f"Заметка #{nid} сохранена.") else: message.chat.send_text("Использование: /note add <текст>") elif subcmd.lower() in ("list", "список", ""): notes = get_notes(chat_id) if not notes: message.chat.send_text("Заметок нет. /note add <текст> — добавить.") else: lines = [f"Заметки ({len(notes)}):"] for n in notes: lines.append(f"{n['id']}. {n['text']}") message.chat.send_text("\n".join(lines)) elif subcmd.lower().startswith("delete ") or subcmd.lower().startswith("удалить "): id_part = subcmd.split(maxsplit=1)[1].strip() if id_part.isdigit(): if delete_note(chat_id, int(id_part)): message.chat.send_text(f"Заметка #{id_part} удалена.") else: message.chat.send_text(f"Заметка #{id_part} не найдена.") else: message.chat.send_text("Использование: /note delete <номер>") else: message.chat.send_text( "/note add <текст> — добавить\n" "/note list — список\n" "/note delete — удалить" ) elif text.startswith("/rate"): parts = text.split() currencies = parts[1:] if len(parts) > 1 else ["USD", "EUR", "CNY", "GBP"] currencies = [c.upper() for c in currencies] try: rates = get_exchange_rates() date_str = datetime.now(IRKUTSK_TZ).strftime("%d.%m.%Y") lines = [f"Курс ЦБ РФ на {date_str}:"] for code in currencies: if code in rates: nominal, value, name = rates[code] if nominal == 1: lines.append(f"{code} — {value:,.2f} ₽ ({name})") else: lines.append(f"{nominal} {code} — {value:,.2f} ₽ ({name})") else: lines.append(f"{code} — не найдено") message.chat.send_text("\n".join(lines)) except Exception as e: message.chat.send_text(f"Ошибка получения курса: {e}") elif text.startswith("/ip"): parts = text.split(maxsplit=1) target = parts[1].strip() if len(parts) > 1 else "" logger.info(f"=== /ip called: chat_id={chat_id}, target='{target}'") try: if not target: r = requests.get("https://api.ipify.org?format=json", timeout=10) r.raise_for_status() ip = r.json()["ip"] logger.info(f"=== /ip result: ip={ip}") message.chat.send_text(f"Внешний IP сервера: {ip}") else: info = lookup_ip_info(target) lines = [f"IP: {info.get('ip', target)}"] if info.get("country"): lines.append(f"Страна: {info['country']}") if info.get("city"): lines.append(f"Город: {info['city']}") if info.get("org"): lines.append(f"Org: {info['org']}") if info.get("hostname"): lines.append(f"Hostname: {info['hostname']}") message.chat.send_text("\n".join(lines)) except Exception as e: message.chat.send_text(f"Ошибка: {e}") elif text.startswith("/dns"): parts = text.split(maxsplit=1) domain = parts[1].strip() if len(parts) > 1 else "" if not domain: message.chat.send_text("Использование: /dns <домен>") else: try: addrs = dns_lookup(domain) lines = [f"DNS: {domain}"] for addr in addrs[:10]: lines.append(f" {addr}") message.chat.send_text("\n".join(lines)) except Exception as e: message.chat.send_text(f"Ошибка DNS: {e}") elif text.startswith("/monitor"): parts = text.split(maxsplit=1) subcmd = parts[1].strip().lower() if len(parts) > 1 else "" monitors = load_monitors() if subcmd.startswith("add "): url = subcmd[4:].strip() if not url.startswith("http"): url = "https://" + url if any(m["url"] == url for m in monitors): message.chat.send_text(f"Уже в мониторинге: {url}") else: ok, detail = check_url(url) monitors.append({ "url": url, "added_chat": chat_id, "last_ok": ok, "last_check": time.time(), "last_detail": detail, }) save_monitors(monitors) status = "доступен" if ok else f"НЕДОСТУПЕН ({detail})" message.chat.send_text(f"Добавлен: {url}\nСтатус: {status}") elif subcmd in ("list", "список", ""): if not monitors: message.chat.send_text("Список пуст. /monitor add ") else: lines = [f"Мониторинг ({len(monitors)}):"] for i, m in enumerate(monitors, 1): icon = "OK" if m.get("last_ok") else "DOWN" checked = datetime.fromtimestamp(m["last_check"], tz=IRKUTSK_TZ).strftime("%H:%M") if m.get("last_check") else "—" lines.append(f"{i}. [{icon}] {m['url']} (проверен {checked})") message.chat.send_text("\n".join(lines)) elif subcmd.startswith("remove "): arg = subcmd[7:].strip() if arg.isdigit(): idx = int(arg) - 1 if 0 <= idx < len(monitors): removed = monitors.pop(idx) save_monitors(monitors) message.chat.send_text(f"Удалён: {removed['url']}") else: message.chat.send_text("Неверный номер. /monitor list — список.") else: url = arg if arg.startswith("http") else "https://" + arg before = len(monitors) monitors = [m for m in monitors if m["url"] != url] if len(monitors) < before: save_monitors(monitors) message.chat.send_text(f"Удалён: {url}") else: message.chat.send_text(f"Не найден: {url}") elif subcmd == "check": if not monitors: message.chat.send_text("Список пуст.") else: lines = ["Проверка:"] for m in monitors: ok, detail = check_url(m["url"]) m["last_ok"] = ok m["last_check"] = time.time() m["last_detail"] = detail icon = "OK" if ok else f"DOWN: {detail}" lines.append(f"[{icon}] {m['url']}") save_monitors(monitors) message.chat.send_text("\n".join(lines)) else: message.chat.send_text( "Мониторинг сайтов:\n" "/monitor add — добавить\n" "/monitor list — список\n" "/monitor remove — удалить\n" "/monitor check — проверить сейчас" ) elif text.startswith("/cal"): parts = text.split(maxsplit=1) subcmd = parts[1].strip().lower() if len(parts) > 1 else "" if subcmd in ("today", "сегодня"): try: evs = cal_list_events(days_ahead=1) if not evs: message.chat.send_text("Сегодня событий нет.") else: lines = [f"Сегодня ({len(evs)}):"] for ev in evs: lines.append(_format_event(ev)) message.chat.send_text("\n".join(lines)) except Exception as e: message.chat.send_text(f"Ошибка CalDAV: {e}") elif subcmd in ("week", "неделя"): try: evs = cal_list_events(days_ahead=7) if not evs: message.chat.send_text("На неделю событий нет.") else: lines = [f"Ближайшие 7 дней ({len(evs)}):"] for ev in evs: lines.append(_format_event(ev)) message.chat.send_text("\n".join(lines)) except Exception as e: message.chat.send_text(f"Ошибка CalDAV: {e}") elif subcmd.startswith("list"): try: list_parts = subcmd.split() days = int(list_parts[1]) if len(list_parts) > 1 and list_parts[1].isdigit() else 30 evs = cal_list_events(days_ahead=days) if not evs: message.chat.send_text(f"Нет событий на {days} дней.") else: lines = [f"События на {days} дней ({len(evs)}):"] for ev in evs: lines.append(_format_event(ev, show_uid=True)) message.chat.send_text("\n".join(lines)) except Exception as e: message.chat.send_text(f"Ошибка CalDAV: {e}") elif subcmd.startswith("add") or subcmd.startswith("добавить"): # Форматы: # /cal add сегодня 15:00 Весь текст как название # /cal add 22 июня 12:00 Весь текст как название # /cal add 12.06 14:30 Весь текст как название # /cal add 15.06.2026 09:00 Весь текст как название rest = subcmd.split(maxsplit=1)[1].strip() if " " in subcmd else "" words = rest.split() # Определяем, занимает ли дата 1 или 2 слова if len(words) >= 4 and words[1].lower() in RU_MONTHS: # "22 июня 12:00 текст..." date_s = f"{words[0]} {words[1]}" time_s = words[2] title = " ".join(words[3:]) else: # "сегодня 12:00 текст..." или "22.06 12:00 текст..." parts = rest.split(maxsplit=2) date_s = parts[0] if len(parts) > 0 else "" time_s = parts[1] if len(parts) > 1 else "" title = parts[2] if len(parts) > 2 else "" if not title: message.chat.send_text( "Использование: /cal add <дата> <время> <название>\n" "Дата: сегодня, завтра, ДД.ММ, ДД.ММ.ГГГГ, 22 июня\n" "Пример: /cal add сегодня 15:00 Встреча с командой" ) else: try: dt_start = _parse_cal_datetime(date_s, time_s) uid = cal_add_event(title, dt_start) local_dt = dt_start.replace(tzinfo=timezone(IRKUTSK_OFFSET)) if dt_start.tzinfo is None else dt_start date_fmt = local_dt.strftime("%d.%m.%Y %H:%M") message.chat.send_text( f"Добавлено: {title}\n{date_fmt}\nID: {uid[:8]}" ) except ValueError as e: message.chat.send_text(f"Ошибка: {e}") except Exception as e: message.chat.send_text(f"Ошибка CalDAV: {e}") elif subcmd.startswith("delete") or subcmd.startswith("удалить"): uid_prefix = subcmd.split(maxsplit=1)[1].strip() if " " in subcmd else "" if not uid_prefix: message.chat.send_text( "Использование: /cal delete \n" "ID можно узнать через /cal list" ) else: try: # Поиск полного UID по префиксу (8 символов) evs = cal_list_events(days_ahead=365) matched = [e for e in evs if e.get("uid", "").startswith(uid_prefix)] if not matched: message.chat.send_text(f"Событие с ID {uid_prefix}... не найдено.") elif len(matched) > 1: lines = ["Несколько совпадений, уточните ID:"] for ev in matched: lines.append(_format_event(ev, show_uid=True)) message.chat.send_text("\n".join(lines)) else: full_uid = matched[0]["uid"] title = matched[0].get("summary", "?") cal_delete_event(full_uid) message.chat.send_text(f"Удалено: {title}") except Exception as e: message.chat.send_text(f"Ошибка CalDAV: {e}") else: message.chat.send_text( "Управление календарём:\n" "/cal today — события на сегодня\n" "/cal week — события на неделю\n" "/cal list [дней] — список событий (до. /cal list 14)\n" "/cal add <дата> <время> <название> [описание]\n" " Дата: сегодня, завтра, ДД.ММ, ДД.ММ.ГГГГ\n" " Пример: /cal add завтра 10:00 Встреча\n" "/cal delete — удалить событие по ID" ) elif text and not text.startswith("/"): if ai_agent.is_ai_enabled(str(chat_id)): try: response = ai_agent.process_message(str(chat_id), text) message.chat.send_text(strip_markdown(response)) except Exception as e: message.chat.send_text(f"❌ Ошибка: {e}") else: message.chat.send_text(HELP_TEXT) def strip_markdown(text): text = re.sub(r'\\([\\`*_{}[\]()#+.!-])', r'\1', text) text = re.sub(r'```[\s\S]*?```', '', text) text = re.sub(r'`([^`]+)`', r'\1', text) text = re.sub(r'\*\*([^*]+?)\*\*', r'\1', text) text = re.sub(r'(?\s?', '', text, flags=re.MULTILINE) text = re.sub(r'^[\s]*[-*+]\s+', '', text, flags=re.MULTILINE) text = re.sub(r'^[\s]*\d+\.\s+', '', text, flags=re.MULTILINE) if urls: text += '\n\n---\n' + '\n'.join(urls) text = re.sub(r'\n{3,}', '\n\n', text) return text.strip() def extract_post_id(link): match = re.search(r'/(\d+)$', link) return match.group(1) if match else "" def telegram_poll_worker(account): while True: try: channels = load_telegram_channels() for ch in channels: if not ch.get('broadcast_chat_id'): continue username = ch['username'] last_id_str = ch.get('last_post_id', '') last_id = int(last_id_str) if last_id_str and last_id_str.isdigit() else 0 t0 = time.time() posts, channel_title, _, _, error = get_telegram_feed(username, 10) elapsed = time.time() - t0 if error or not posts: logger.warning(f"TG Broadcast: Skipping {username} ({elapsed:.1f}s): {error or 'no posts'}") continue logger.info(f"TG Broadcast: Fetched {username} ({elapsed:.1f}s, {len(posts)} posts)") new_posts = [] for post in reversed(posts): pid_str = extract_post_id(post.get('link', '')) if pid_str and pid_str.isdigit(): pid = int(pid_str) if pid > last_id: new_posts.append(post) last_id = pid if not new_posts: continue for post in new_posts: pid_str = extract_post_id(post.get('link', '')) pid = int(pid_str) if pid_str and pid_str.isdigit() else 0 logger.info(f"TG Broadcast: New post in {username}: {pid}") broadcast_id = ch['broadcast_chat_id'] desc_html = post.get('description') or '' enc_url = post.get('enclosure_url', '') or '' title = ch.get('title', username) text = re.sub(r']*>]*>\s*', '', desc_html) text = re.sub(r']*>', '', text) # Extract URLs from tags, keep only link text. Skip max.ru link_urls = [] def collect_url(m): url = m.group(1) linktext = m.group(2).strip() if 'max.ru' not in url.lower(): link_urls.append(url) return linktext if linktext else url text = re.sub(r']*?href="([^"]*)"[^>]*>\s*(.*?)\s*', collect_url, text, flags=re.DOTALL) text = re.sub(r'', '\n', text, flags=re.IGNORECASE) text = re.sub(r'<[^>]+>', '', text) text = html.unescape(text).strip() text = re.sub(r'\n{3,}', '\n\n', text) text = re.sub(r' {2,}', ' ', text) # Append extracted URLs as footnotes (skip duplicates and URLs already in text) seen = set() footer_links = [] for u in link_urls: if u not in seen and u not in text: seen.add(u) footer_links.append(u) if footer_links: text += '\n\n---\n' + '\n'.join(footer_links) logger.info(f"TG Broadcast: Post {pid} enc_url={'yes' if enc_url else 'no'}, text_len={len(text)}") try: msg_data = {"text": text} tmp_path = None enc_type = post.get('enclosure_type', '') if enc_url and enc_type.startswith('image/'): try: logger.info(f"TG Broadcast: Downloading {enc_url}") img_resp = requests.get(enc_url, timeout=60, headers={'User-Agent': 'Mozilla/5.0'}) if img_resp.status_code == 200: ext = enc_type.split('/')[-1] if '/' in enc_type else 'jpg' with tempfile.NamedTemporaryFile(suffix=f'.{ext}', delete=False) as f: f.write(img_resp.content) tmp_path = f.name logger.info(f"TG Broadcast: Downloaded {len(img_resp.content)} bytes -> {tmp_path}") msg_data["file"] = tmp_path else: logger.debug(f"TG Broadcast: Download returned {img_resp.status_code}") except Exception as e: logger.debug(f"TG Broadcast: Download failed: {e}") account._rpc.send_msg(account.id, broadcast_id, msg_data) if tmp_path: os.unlink(tmp_path) ch['last_post_id'] = pid_str ch['title'] = channel_title if channel_title else ch.get('title', username) save_telegram_channels(channels) logger.info(f"TG Broadcast: Sent to {username}, pid={pid}") except Exception as e: logger.error(f"TG Broadcast: Error sending {pid}: {e}") except Exception as e: logger.error(f"TG Broadcast: Poll error: {e}") time.sleep(TG_POLL_INTERVAL) def send_daily_status_worker(account): while True: now_irkutsk = datetime.now(IRKUTSK_TZ) target_time = now_irkutsk.replace(hour=9, minute=0, second=0, microsecond=0) if now_irkutsk >= target_time: target_time += timedelta(days=1) wait_seconds = (target_time - now_irkutsk).total_seconds() logger.info(f"Next status report at {target_time.strftime('%Y-%m-%d %H:%M:%S')} (Irkutsk time). Waiting {wait_seconds/3600:.1f} hours.") time.sleep(wait_seconds) server_status = get_system_status() weather = get_weather(DEFAULT_CITY) report = f"{server_status}\n\n{weather}" try: evs = cal_list_events(days_ahead=1) if evs: cal_lines = [f"\nСобытия на сегодня ({len(evs)}):"] for ev in evs: cal_lines.append(_format_event(ev)) report += "\n" + "\n".join(cal_lines) else: report += "\n\nСобытий на сегодня нет." except Exception as e: logger.warning(f"CalDAV daily report error: {e}") subscribers = load_subscribers() for accid, chat_id in list(subscribers): try: account._rpc.send_msg(account.id, chat_id, {"text": report}) except Exception as e: logger.error(f"Error sending daily status: {e}") def _process_secure_join(account, reply_chat, qr_code): try: info = account._rpc.check_qr(account.id, qr_code) logger.info(f"check_qr result: {info}") except Exception as e: logger.warning(f"check_qr failed: {e}") info = None try: result = account._rpc.secure_join(account.id, qr_code) logger.info(f"secure_join result: {result}") reply_chat.send_text(f"✅ Secure join завершён: {result}") except Exception as e: logger.error(f"secure_join error: {e}") reply_chat.send_text(f"❌ Secure join не удался: {e}\ninfo={info}") def main(): rpc_server_path = "/home/alexabudaev/delta-bot/.venv/bin/deltachat-rpc-server" with Rpc(rpc_server_path=rpc_server_path) as rpc: deltachat = DeltaChat(rpc) system_info = deltachat.get_system_info() logger.info(f"Running deltachat core {system_info.deltachat_core_version}") accounts = deltachat.get_all_accounts() if not accounts: logger.error("No accounts found. Please configure an account first.") sys.exit(1) account = accounts[0] if not account.is_configured(): logger.error("Account is not configured.") sys.exit(1) account.set_config("bot", "1") if not account.get_config("displayname"): account.set_config("displayname", "DeltaBot") account.set_config("selfstatus", "Delta Chat Bot - /help") account.set_config("imap_certificate_checks", "2") logger.info("Account configured, starting IO...") deltachat.start_io() bot = Bot(account, hooks) threading.Thread(target=telegram_poll_worker, args=(account,), daemon=True).start() threading.Thread(target=send_daily_status_worker, args=(account,), daemon=True).start() threading.Thread(target=cal_reminder_worker, args=(account,), daemon=True).start() threading.Thread(target=monitor_worker, args=(account,), daemon=True).start() logger.info("Bot started, running forever...") bot.run_forever() if __name__ == "__main__": main()