2433 lines
No EOL
105 KiB
Python
2433 lines
No EOL
105 KiB
Python
#!/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"¤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():
|
||
data = load_json(MONITORS_FILE, {})
|
||
if isinstance(data, list):
|
||
old = data
|
||
data = {}
|
||
for m in old:
|
||
cid = m.pop("added_chat", None)
|
||
if cid:
|
||
data.setdefault(str(cid), []).append(m)
|
||
save_monitors(data)
|
||
return data
|
||
|
||
def save_monitors(data):
|
||
save_json(MONITORS_FILE, data)
|
||
|
||
def get_chat_monitors(data, chat_id):
|
||
return data.get(str(chat_id), [])
|
||
|
||
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 chat_id, chat_monitors in monitors.items():
|
||
for m in chat_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}"
|
||
try:
|
||
account._rpc.send_msg(account.id, int(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()
|
||
chat_monitors = get_chat_monitors(monitors, chat_id)
|
||
|
||
if subcmd.startswith("add "):
|
||
url = subcmd[4:].strip()
|
||
if not url.startswith("http"):
|
||
url = "https://" + url
|
||
if any(m["url"] == url for m in chat_monitors):
|
||
message.chat.send_text(f"Уже в мониторинге: {url}")
|
||
else:
|
||
ok, detail = check_url(url)
|
||
chat_monitors.append({
|
||
"url": url,
|
||
"last_ok": ok,
|
||
"last_check": time.time(),
|
||
"last_detail": detail,
|
||
})
|
||
monitors[str(chat_id)] = chat_monitors
|
||
save_monitors(monitors)
|
||
status = "доступен" if ok else f"НЕДОСТУПЕН ({detail})"
|
||
message.chat.send_text(f"Добавлен: {url}\nСтатус: {status}")
|
||
|
||
elif subcmd in ("list", "список", ""):
|
||
if not chat_monitors:
|
||
message.chat.send_text("Список пуст. /monitor add <url>")
|
||
else:
|
||
lines = [f"Мониторинг ({len(chat_monitors)}):"]
|
||
for i, m in enumerate(chat_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(chat_monitors):
|
||
removed = chat_monitors.pop(idx)
|
||
monitors[str(chat_id)] = chat_monitors
|
||
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(chat_monitors)
|
||
chat_monitors = [m for m in chat_monitors if m["url"] != url]
|
||
if len(chat_monitors) < before:
|
||
monitors[str(chat_id)] = chat_monitors
|
||
save_monitors(monitors)
|
||
message.chat.send_text(f"Удалён: {url}")
|
||
else:
|
||
message.chat.send_text(f"Не найден: {url}")
|
||
|
||
elif subcmd == "check":
|
||
if not chat_monitors:
|
||
message.chat.send_text("Список пуст.")
|
||
else:
|
||
lines = ["Проверка:"]
|
||
for m in chat_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']}")
|
||
monitors[str(chat_id)] = chat_monitors
|
||
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() |