delta-chat-bot/deltabot.py
Алексей Будаев d5f84d6254 deltabot.py: sync changes
2026-06-13 23:21:27 +08:00

2418 lines
No EOL
104 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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 <email> - Добавить контакт в белый список\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"&current=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"""<?xml version="1.0" encoding="utf-8"?>
<C:calendar-query xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
<D:prop>
<D:getetag/>
<C:calendar-data/>
</D:prop>
<C:filter>
<C:comp-filter name="VCALENDAR">
<C:comp-filter name="VEVENT">
<C:time-range start="{dtstart}" end="{dtend}"/>
</C:comp-filter>
</C:comp-filter>
</C:filter>
</C:calendar-query>"""
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 <email> — удалить")
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 <N> — удалить"
)
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 <url>")
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 <url> — добавить\n"
"/monitor list — список\n"
"/monitor remove <N|url> — удалить\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 <ID>\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> — удалить событие по 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'(?<!\*)\*([^*]+?)\*(?!\*)', r'\1', text)
text = re.sub(r'__([^_]+?)__', r'\1', text)
text = re.sub(r'(?<!\S)_(\S[\s\S]*?\S)_(?!\S)', r'\1', text)
urls = []
def _link_repl(m):
t = m.group(1).strip()
u = m.group(2).strip()
if t:
urls.append(u)
return t
return u
text = re.sub(r'(?<!!)\[([^\]]*)\]\(([^)]*)\)', _link_repl, text)
text = re.sub(r'!\[([^\]]*)\]\(([^)]*)\)', r'\1', text)
text = re.sub(r'^#{1,6}\s+', '', text, flags=re.MULTILINE)
text = re.sub(r'^([-*_]\s?){3,}$', '', text, flags=re.MULTILINE)
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'<a\s+[^>]*><img\s+[^>]*></a>\s*', '', desc_html)
text = re.sub(r'</?tg-emoji[^>]*>', '', text)
# Extract URLs from <a> 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'<a\s+[^>]*?href="([^"]*)"[^>]*>\s*(.*?)\s*</a>', collect_url, text, flags=re.DOTALL)
text = re.sub(r'<br\s*/?>', '\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()