commit 8f47610133e4311e8e953e2e67c3f954910ba0f9 Author: Алексей Будаев Date: Sat Jun 13 15:53:05 2026 +0800 Initial commit: delta-chat-bot diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b25004e --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +__pycache__/ +.DS_Store +*.bak +*.bak.* diff --git a/AGENTS.MD b/AGENTS.MD new file mode 100644 index 0000000..75eaa0d --- /dev/null +++ b/AGENTS.MD @@ -0,0 +1,587 @@ +# Delta Chat Bot: deltabot + +## ⚠️ Важно: как мы работаем + +- Я работаю на **локальном Mac** (`/Users/alexabudaev/Documents/Zed/`), не на сервере. +- Не пытаюсь башить команды на макбуке — все изменения в `deltabot.py` только локально редактируются. +- Загрузка на сервер — ручная: + - Бот: `scp deltabot.py SERVER:~/delta-bot/deltabot.py && ssh SERVER sudo systemctl restart deltabot` + - Прокси (PHP — без перезапуска): `scp php-proxy/*.php php-proxy/.htaccess php-proxy/channels.json SERVER:~/delta-bot/php-proxy/` +- Сервер: Ubuntu 24.04, бот под `alexabudaev`, Postfix/Dovecot под `bot`. + +## Архитектура + +``` +deltabot.py + └── deltachat_rpc_client (Bot, DeltaChat, Rpc) + └── deltachat-rpc-server (Rust Core) +``` + +## Структура проекта + +- **`deltabot.py`** — основной скрипт бота (JSON-RPC). +- **`AGENTS.MD`** — документация проекта. +- **Конфиги и БД:** `~/.config/deltabot/` (subscribers, channels, pending, bridges) +- **Аккаунты DC:** `~/delta-bot/accounts/` + +## Развёртывание + +```bash +# После изменений в deltabot.py: +scp /Users/alexabudaev/Documents/Zed/delta-bot/deltabot.py alexabudaev@10.0.0.1:/home/alexabudaev/delta-bot/deltabot.py +ssh alexabudaev@10.0.0.1 sudo systemctl restart deltabot + +# После изменений в php-proxy/ (перезагрузка не нужна — PHP): +scp /Users/alexabudaev/Documents/Zed/delta-bot/php-proxy/*.php alexabudaev@10.0.0.1:/home/alexabudaev/delta-bot/php-proxy/ +scp /Users/alexabudaev/Documents/Zed/delta-bot/php-proxy/.htaccess alexabudaev@10.0.0.1:/home/alexabudaev/delta-bot/php-proxy/ +scp /Users/alexabudaev/Documents/Zed/delta-bot/php-proxy/channels.json alexabudaev@10.0.0.1:/home/alexabudaev/delta-bot/php-proxy/ + +# Если менялся только .htaccess — только scp .htaccess + +# Логи: +sudo journalctl -u deltabot -f | grep "TG Broadcast" +``` + +### Natural Language → Commands + +Любое сообщение, не начинающееся с `/`, проходит через `parse_natural_language()` (строка 101): + +| Фраза | Команда | +|---|---| +| `погода/weather/прогноз [город] [на N дней/неделю]` | `/weather ...` | +| `дай/мосты/bridge/tor [obfs4/vanilla/ipv6]` | `/bridges ...` | +| `статус/status/состояние` | `/status` | +| `помощь/help/команды/что умеешь` | `/help` | +| `подпишись/подписаться/subscribe` | `/subscribe` | +| `отпишись/отписаться/unsubscribe` | `/unsubscribe` | +| `qr/qrcode/кьюар` | `/qr` | + +### Переменные окружения (`.env`) + +Секреты вынесены в `~/.config/deltabot/.env`: + +``` +GMAIL_EMAIL=dtorbot@gmail.com +GMAIL_APP_PASSWORD=eciu llvw sjjz ygbb +CLOUDFLARE_ACCOUNT_ID=7fb91c91800780ae7dcdf5d47cdf9a1d +CLOUDFLARE_API_TOKEN= +OPENROUTER_API_KEY=sk-or-... +CALDAV_URL=https://baikal.budaev.org/dav.php/calendars/alex@budaev.org/ai-notifications/ +CALDAV_USER=alex@budaev.org +CALDAV_PASSWORD=<пароль> +``` + +Права: `chmod 600 ~/.config/deltabot/.env`. systemd подхватывает через `EnvironmentFile=` в `deltabot.service`. + +**Обязательные** (без них не работают мосты): `GMAIL_EMAIL`, `GMAIL_APP_PASSWORD`. **Для AI:** `CLOUDFLARE_ACCOUNT_ID`, `CLOUDFLARE_API_TOKEN`. `OPENROUTER_API_KEY` — опционально (fallback). **Для CalDAV:** `CALDAV_USER`, `CALDAV_PASSWORD` (URL зашит как дефолт). + +## Команды бота + +- **`/status`** — состояние сервера +- **`/weather [город] [дни]`** — погода (3/7/10 дней) +- **`/bridges [obfs4|vanilla|ipv6]`** — Tor мосты +- **`/save [путь]`** — сохранить прикреплённый файл +- **`/subscribe` / `/unsubscribe`** — ежедневный отчёт (09:00 IRKT) +- **`/channels list|add|remove|invite|description|image`** — управление broadcast каналами +- **`/telegram [N]`** — посты из TG канала +- **`/cal today|week|list|add|delete`** — управление CalDAV-календарём +- **`/note add|list|delete`** — заметки (per-chat) +- **`/rate [валюта...]`** — курс валют ЦБ РФ +- **`/ip [адрес]`** — внешний IP сервера или информация об адресе +- **`/dns <домен>`** — DNS-запрос +- **`/monitor add|list|remove|check`** — мониторинг сайтов +- **`/ai summary`** — краткое резюме истории AI-чата +- Голосовые сообщения → автоматическая транскрипция (Whisper via Cloudflare) +- **`/qr`** — QR код для добавления бота +- **`/join https://i.delta.chat/#...`** — secure join +- **`/addcontact `** — добавить email или домен в белый список (только для верифицированных) +- **`/contacts list|remove`** — просмотр и управление белым списком +- **`/help`** — справка + +> ⚠️ **/help и echo (ответ на не-команду) — один HELP_TEXT.** Определён как константа в начале файла. При добавлении/изменении команд править только HELP_TEXT. Echo не отстанет от help. + +## PHP Proxy (кэширующий прокси для tg.i-c-a.su) + +Архитектура: +``` +tg.i-c-a.su (10-17s) → proxy.budaev.org (rss.php + media.php + кэш) → бот (<0.1s) +``` + +Исходники: `php-proxy/` — заливаются на `proxy.budaev.org` (хостинг с ISPManager). + +**Два PHP-скрипта (без cron — кэш наполняется лениво):** + +| Файл | Роль | +|------|------| +| `rss.php` | GET `?channel=CHANNEL&limit=N`: отдаёт RSS из кэша (TTL 600с + stagger), при промахе проксирует с tg.i-c-a.su | +| `media.php` | GET `?url=...`: отдаёт картинку из кэша (TTL 24ч), при промахе скачивает и кэширует | + +**`rss.php`** (оптимизирован 02.06.2026): +- **Pre-rewritten cache:** XML сохраняется в кэш уже с переписанными `` (на media.php). При cache hit — `readfile()` без DOMDocument. +- **Staggered TTL:** `600 + crc32(channel) % 300` секунд, чтобы каналы не истекали одновременно. +- **Combined DOMDocument:** fetch + rewrite в одном проходе (раньше было два DOMDocument на miss). +- **Empty feed вместо 502:** при ошибке tg.i-c-a.su и отсутствии кэша возвращается пустой RSS, чтобы бот не падал на fallback и не получал сырые enclosure URL. + +**`media.php`** (оптимизирован 02.06.2026): +- **Probabilistic cleanup:** `maybeCleanOldCache()` — при ~2% запросов удаляет файлы старше 24ч. Без cron. +- **Cache-Control унифицирован:** и HIT и MISS отдают `max-age=86400` (было 604800 на MISS — неконсистентно с TTL). + +**`channels.json`** — список каналов (для информации, работа не зависит от него). + +**Новый канал:** первый poll медленный (10-30с, rss.php проксирует), со второго — мгновенно. + +Телеграм с хостинга недоступен (блокировка), поэтому `rss.php` ходит через `tg.i-c-a.su`. + +### IP restriction + +Доступ к `proxy.budaev.org` разрешён только с IP бота (90.188.48.201). Реализовано через `.htaccess`: +```apache +RewriteCond %{REMOTE_ADDR} !^90\.188\.48\.201$ +RewriteRule ^ - [F,L] +``` +Остальные IP получают 403 Forbidden на уровне Apache (до PHP не доходит). + +### Empty feed вместо 502 + +При ошибке tg.i-c-a.su, если нет даже старого кэша, rss.php возвращает пустой RSS `...` с HTTP 200, вместо 502. Это не даёт боту упасть на fallback (tg.i-c-a.su напрямую), который возвращает сырые enclosure URL без перезаписи на media.php. + +### Upstream timeout + +`rss.php` ждёт ответ от tg.i-c-a.su до 30 секунд (было 20, увеличено 24.05.2026 из-за тяжёлых каналов типа gremtelegram, которые не влезали в 20с). + +## Telegram Channels (Broadcast) + +- **Прокси (основной):** `https://proxy.budaev.org/rss` (`TG_RSS_PROXY`) +- **Прокси (fallback):** `https://tg.i-c-a.su/rss` (`TG_RSS_FALLBACK`) — если proxy.budaev.org недоступен +- Бот пробует сначала основной, при ошибках — fallback (`get_telegram_feed()`, строки 189-238) +- Polling каждые 90 секунд, `limit=10`, обработка всех новых постов (batch) +- Кэш RSS: 600с TTL. Из ~7 poll-ов 1 медленный (10-30с для тяжёлых каналов), остальные — мгновенные (<0.1с) +- При пропуске канала (ошибка RSS) — `logger.warning` с причиной и временем запроса +- Для каждого канала логируется какой прокси сработал и сколько времени занял запрос: `TG Broadcast: Fetched gremtelegram (20.6s, 4 posts)` +- Неудачные скачивания картинок — `logger.debug` (раньше был warning, засорял логи) +- Описание канала устанавливается из RSS при создании. Картинка — вручную через `/channels image` + +### Enclosure-картинки + +Прокси (`tg.i-c-a.su`) добавляет `` в RSS для постов с медиа. Бот: +1. Читает первый `` из RSS +2. Если `type` начинается с `image/` — скачивает эту картинку +3. Прикрепляет к тексту поста (поле `file` в `send_msg`) +4. Удаляет временный файл после отправки +5. Если скачать не удалось — пост уходит без картинки + +### HTML → plain text + +Delta Chat не рендерит Markdown. Из HTML вырезаются все теги: + +| Тег в RSS | Результат | +|-----------|-----------| +| `
` | перенос строки | +| `text` | сохраняется только `text`, URL → сноска | +| `...` | URL не попадает в сноску, текст сохраняется | +| Сноска | `---\nurl1\nurl2` | +| ``, ``, `
` и т.д. | удаляются | +| `` / `` | удаляются | +| HTML entities | `html.unescape()` | +| `` | fallback, если `<description>` пустой | + +Код: `telegram_poll_worker()`. Ссылки через callback `collect_url()` — max.ru исключаются. + +## CalDAV — Управление календарём + +**Статус:** работает +**Сервер:** Baikal на `baikal.budaev.org` +**Календарь:** `https://baikal.budaev.org/dav.php/calendars/alex@budaev.org/ai-notifications/` +**Зависимости:** только `requests` (уже в зависимостях), `uuid`, `xml.etree.ElementTree` (stdlib) + +### Команды + +| Команда | Действие | +|---------|----------| +| `/cal today` | События на сегодня (UTC → Иркутск) | +| `/cal week` | События на 7 дней | +| `/cal list [N]` | События на N дней (по умолч. 30), с короткими ID | +| `/cal add <дата> <время> <название> [описание]` | Добавить событие | +| `/cal delete <ID>` | Удалить событие по коротому ID (первые 8 символов UID) | + +**Форматы даты** в `/cal add`: `сегодня`, `завтра`, `ДД.ММ`, `ДД.ММ.ГГГГ` +**Примеры:** +``` +/cal add сегодня 15:00 Встреча с командой +/cal add завтра 10:30 Визит врача онлайн +/cal add 20.06 09:00 Конференция описание здесь +``` + +### Архитектура + +``` +handle_message() → /cal subcmd + ├── today/week → cal_list_events(days) → REPORT (CalDAV) → форматирование + ├── list [N] → cal_list_events(N) → REPORT (CalDAV) → форматирование + short ID + ├── add → _parse_cal_datetime() → cal_add_event() → PUT (CalDAV) + └── delete <ID> → cal_list_events(365) → поиск по UID-префиксу → cal_delete_event() → DELETE +``` + +### HTTP-методы CalDAV + +| Операция | Метод | Тело | +|----------|-------|------| +| Получение событий | `REPORT` + `Depth: 1` | XML calendar-query с time-range | +| Создание события | `PUT` на `<uid>.ics` | iCalendar (VCALENDAR/VEVENT) | +| Удаление события | `DELETE` на `<uid>.ics` | — | + +Часовой пояс событий: `Asia/Irkutsk` (UTC+8). При создании пишется `TZID=Asia/Irkutsk` в DTSTART/DTEND. + +### Переменные окружения + +``` +CALDAV_URL=https://baikal.budaev.org/dav.php/calendars/alex@budaev.org/ai-notifications/ +CALDAV_USER=alex@budaev.org +CALDAV_PASSWORD=<пароль_baikal> +``` + +`CALDAV_URL` имеет дефолтное значение в коде — можно не задавать, если URL не меняется. + +--- + +## Secure join + +Единственный способ добавить контакт (`create_contact` RPC сломан на core 2.49.0). Бот обрабатывает ссылку `https://i.delta.chat/#...` в фоновом потоке через `account._rpc.secure_join()`. Чтобы handshake завершился, получатель должен ответить (принять приглашение). Пока ответа нет — `key is missing` при отправке. + +## Mail сервер (dc.budaev.org) + +### Системный пользователь bot + +- **Postfix:** приём на 25 (postscreen), submission на 587 (STARTTLS + SASL), доставка напрямую на MX (relayhost выключен). +- **Dovecot:** SASL через PAM (пользователь `bot`), maildir в `~/Maildir`. +- **Сертификаты:** Let's Encrypt, `/etc/letsencrypt/live/dc.budaev.org/`. +- **sasl_passwd:** `[dc.budaev.org]:587 bot:ПАРОЛЬ` (username `bot`, не `bot@...`). +- **Важно:** `relayhost` не ставить — loop. `smtp_tls_wrappermode` / `smtp_use_tls` не использовать. + +### Виртуальные ящики (Dovecot LMTP + passwd-file) + +Для дополнительных адресов (например, `macky@dc.budaev.org`) используется Dovecot LMTP + `passwd-file`. + +**Postfix (main.cf):** +``` +transport_maps = hash:/etc/postfix/transport +``` +**Transport** (`/etc/postfix/transport`): +``` +macky@dc.budaev.org lmtp:unix:private/dovecot-lmtp +``` + +**Dovecot LMTP** (`/etc/dovecot/conf.d/20-lmtp.conf`): +``` +service lmtp { + unix_listener /var/spool/postfix/private/dovecot-lmtp { + group = postfix + mode = 0600 + user = postfix + } +} +``` +⚠️ На Ubuntu пользователь `postfix`, не `_postfix` (как на macOS). + +**passwd-file auth** (`/etc/dovecot/conf.d/auth-passwdfile.conf.ext`): +``` +passdb { + driver = passwd-file + args = scheme=crypt username_format=%u /etc/dovecot/users +} +userdb { + driver = passwd-file + args = username_format=%u /etc/dovecot/users + default_fields = uid=5000 gid=5000 home=/var/mail/vhosts/dc.budaev.org/%n +} +``` + +**Users** (`/etc/dovecot/users`, формат passwd-file): +``` +macky@dc.budaev.org:{SHA512-CRYPT}$6$...:5000:5000::/var/mail/vhosts/dc.budaev.org/macky:/usr/sbin/nologin +``` + +Порядок настройки для нового виртуального ящика: +1. Установить `dovecot-lmtpd` (отдельный пакет на Ubuntu) +2. Создать конфиги LMTP и auth-passwdfile +3. Раскомментировать `!include auth-passwdfile.conf.ext` в `/etc/dovecot/conf.d/10-auth.conf` +4. Создать запись в `/etc/dovecot/users` (хэш через `doveadm pw -s SHA512-CRYPT`) +5. Создать Maildir с uid/gid из userdb: `mkdir -p /var/mail/vhosts/dc.budaev.org/macky/Maildir/{new,cur,tmp}; chown -R 5000:5000` +6. Добавить transport в Postfix: `echo 'macky@dc.budaev.org lmtp:unix:private/dovecot-lmtp' >> /etc/postfix/transport && postmap /etc/postfix/transport` +7. Перезапустить dovecot + postfix + +### Известные грабли mail + +1. **relayhost** — НЕ СТАВИТЬ. Бот аутентифицирован на submission:587, Postfix сам доставляет на внешние MX. +2. **smtp_tls_wrappermode / smtp_use_tls** — ломают TLS. Не использовать. +3. **`bot@dc.budaev.org` vs `bot`** — в sasl_passwd username должен быть `bot`, не `bot@dc.budaev.org`. +4. **key_id в БД** — после DELETE keypairs.id autoincrement не переиспользуется. Убедись что key_id в config совпадает с реальным id в keypairs. +5. **Spamhaus PBL** — IP 90.188.48.201 в списке, iCloud/Gmail могут отклонять почту пока PTR не заработает. +6. **dovecot-lmtpd** — обязательный пакет на Ubuntu, без него LMTP сокет не создаётся. +7. **Group `postfix` vs `_postfix`** — на Ubuntu пользователь `postfix`, на macOS `_postfix`. +8. **Permission denied autocreate Maildir** — виртуальный ящик не может создать Maildir сам, нужно создать вручную с uid/gid, совпадающим с `default_fields` в userdb. +9. **`local_recipient_maps =`** — обязательно очистить (Postfix иначе rejected для виртуальных), доставка через transport_maps. + +## DKIM + +OpenDKIM milter (`Mode sv`) верифицирует входящие, но не вызывается для исходящих (submission). Решение не deployed. Документировано в `AGENTS.MD.bak` — dkimpy pipe after-queue. + +## Bridges (Tor мосты) + +Запрос через Gmail-посредник. Кэш 6ч, лимит 5 мостов на тип. Баг дубликатов (16.05.2026) починен: добавлен `_tor_lock`, сохранение `delivered` в файл, проверка в цикле. + +## Известные проблемы + +1. **"key is missing" при отправке в новый чат** — secure join handshake не завершён. +2. **Описание канала** — `set_chat_description` требует core v2.50.0 (не released). +3. **Аватарка канала** — `set_image()` может не работать на core v2.49.0. +4. **Greylisting на mail.budaev.org** — `451-Greylisted`, потом `421 Too many connections`. +5. **Spamhaus PBL** — PTR для 90.188.48.201 ещё не разрезолвился. +6. **tg.i-c-a.su ненадёжен** — частые таймауты (17s+), per-channel баны, 15 RPM. PHP-прокси на budaev.org смягчает (кэш + fallback). +7. **Unbound на сервере** — медленный резолвинг (122ms), рекомендуется форвардинг на 1.1.1.1. + +## Планы на оптимизацию + +1. Поддержка аватарок каналов (`chat.set_image()`). +2. Очистка старых аккаунтов (`~/.local/share/deltachat/accounts/`). +3. Автообновление LE сертификата (certbot renew + хуки). +4. Уменьшить MDN (уведомления о прочтении). + +## Выполненные оптимизации + +### 02.06.2026 — Proxy + bot + +- **`rss.php`:** pre-rewritten cache (cache hit = `readfile()`, без DOMDocument), staggered TTL (600 + crc32%300), combined DOMDocument. +- **`rss.php`:** пустой RSS вместо 502 при ошибке tg.i-c-a.su — бот не падает на fallback. +- **`.htaccess`:** убрано правило прямого кэша (мешало: нет проверки TTL, отдавало устаревшие файлы). +- **`media.php`:** `maybeCleanOldCache()` — вероятностная очистка (~2% запросов) файлов старше 24ч. +- **`deltabot.py`:** `TG_POLL_INTERVAL` 90→180с. +- **`channels.json`:** синхронизирован с реальным списком (6 каналов: markettwits, raiznews, droidergram, gremtelegram, postnauka, kartiny2). + +### 13.06.2026 — Security fixes + +- **`deltabot.py` `smtp_send()`:** убраны `context.check_hostname = False` и `context.verify_mode = ssl.CERT_NONE` — TLS к Gmail теперь с полной проверкой сертификата. Ранее Gmail app password передавался через непроверенное соединение. +- **`deltabot.py` `/save`:** ограничение размера файла до 50 МБ (`os.path.getsize`) до base64-кодирования — защита от DoS через pending_save.json. +- **`deltabot.py` `/join`:** проверка URL заменена с `"i.delta.chat" in value` на `re.match(r'https://i\.delta\.chat/#', value)` — устраняет обход подстрокой. +- **`deltabot.py` `/save` и `/channels image`:** `file_blob` валидируется через `os.path.realpath` + проверка что результирующий путь остаётся внутри `ACCOUNTS_DIR` — защита от path traversal. + +### 13.06.2026 — Изоляция бота + +- **`deltabot.py`:** добавлена проверка отправителя в начале `handle_message`. Сообщения от неизвестных email игнорируются без ответа. +- **`deltabot.py`:** `is_contact_allowed()` — проверяет `isVerified` (JSON-RPC), затем `TRUSTED_DOMAINS` (zашиты в коде: `budaev.org`, `dc.budaev.org`), затем `allowed_contacts.json`. +- **`deltabot.py`:** `_matches_allowlist()` — поддержка масок `*@domain`. +- **`deltabot.py`:** команды `/addcontact` и `/contacts list|remove` — управление белым списком; изменения доступны только верифицированным контактам. + +### 04.06.2026 — Poll interval decreased + +- **`deltabot.py`:** `TG_POLL_INTERVAL` 180→90с — интервал уменьшен для устранения пропусков в часто обновляемых каналах. +- **`deltabot.py`:** Мульти-енклозур откачен (один пост — одна картинка, как раньше). + +### 26.05.2026 — strip_markdown + +- **`deltabot.py`:** добавлена `strip_markdown()` — удаляет `**`, `*`, `` ` ``, `[]()` (→ сноски), `#`, списки, цитаты, `---` из AI-ответов перед отправкой. + +## Изоляция бота (безопасность) + +**Статус:** реализовано (13.06.2026) + +Бот отвечает **только** контактам из белого списка. Все остальные сообщения молча игнорируются (`logger.info`) — бот не отвечает и не подтверждает получение. + +### Логика проверки (`is_contact_allowed`) + +Для каждого входящего сообщения в самом начале `handle_message`: + +``` +is_contact_allowed(account, from_id) + 1. account._rpc.get_contact(account.id, from_id) + 2. contact['isVerified'] == True → разрешить (SecureJoin/QR) + 3. contact['address'] in TRUSTED_DOMAINS → разрешить (домен в коде) + 4. contact['address'] in allowed_contacts.json → разрешить (явный список) + 5. иначе → игнорировать +``` + +### Доверенные домены (зашиты в коде) + +```python +TRUSTED_DOMAINS = {"budaev.org", "dc.budaev.org"} +``` + +Любой адрес `*@budaev.org` или `*@dc.budaev.org` проходит без SecureJoin. + +### Белый список (`allowed_contacts.json`) + +**Файл:** `~/.config/deltabot/allowed_contacts.json` +**Формат:** `["user@example.com", "*@otherdomain.org"]` + +Поддерживаются: +- Точный email: `user@example.com` +- Доменная маска: `*@example.com` + +### Управление белым списком + +| Команда | Кто может | Действие | +|---------|-----------|----------| +| `/addcontact user@example.com` | верифицированный контакт | добавить email | +| `/addcontact *@domain.org` | верифицированный контакт | добавить домен-маску | +| `/contacts list` | любой разрешённый | показать список | +| `/contacts remove user@example.com` | верифицированный контакт | удалить из списка | + +Право на изменение белого списка — только у контактов с `isVerified = True` (добавленных через SecureJoin/QR). + +### Известные ограничения + +- `isVerified` в JSON-RPC требует **двусторонней** верификации. После `/join` бот верифицирует пользователя, но сам `isVerified` может остаться `False` до завершения handshake. В таком случае пользователь должен находиться в `TRUSTED_DOMAINS` или `allowed_contacts.json`. +- При `/join` может создаваться новый неверифицированный контакт — старый верифицированный остаётся отдельно. Workaround: использовать `TRUSTED_DOMAINS` для своих доменов. + +--- + +## Заметки (/note) + +**Файл:** `~/.config/deltabot/notes.json` +**Формат:** `{chat_id: [{id, text, created}]}` +**ID** — автоинкремент в рамках каждого чата. + +| Команда | Действие | +|---------|----------| +| `/note add <текст>` | Добавить заметку | +| `/note list` | Список заметок чата | +| `/note delete <N>` | Удалить по ID | + +--- + +## Курс валют (/rate) + +**API:** ЦБ РФ `https://www.cbr.ru/scripts/XML_daily.asp` (XML, без ключа) +**Кэш:** в памяти, TTL 1 час (`_rates_cache` dict). + +| Команда | Действие | +|---------|----------| +| `/rate` | USD, EUR, CNY, GBP | +| `/rate USD EUR` | Указанные валюты | + +--- + +## Сетевые утилиты (/ip, /dns) + +| Команда | Действие | +|---------|----------| +| `/ip` | Внешний IP сервера (api.ipify.org) | +| `/ip 8.8.8.8` | Страна, город, org (ipinfo.io, 50k/мес free) | +| `/dns budaev.org` | DNS A/AAAA (socket.getaddrinfo) | + +--- + +## Мониторинг сайтов (/monitor) + +**Файл:** `~/.config/deltabot/monitors.json` +**Воркер:** `monitor_worker()` — проверяет каждые 5 минут, шлёт алерт всем подписчикам при смене статуса. + +| Команда | Действие | +|---------|----------| +| `/monitor add <url>` | Добавить (мгновенная проверка при добавлении) | +| `/monitor list` | Список с последним статусом | +| `/monitor remove <N\|url>` | Удалить по номеру или URL | +| `/monitor check` | Немедленная проверка всех | + +Алерт при падении отправляется **всем подписчикам** (`subscribers.json`). + +--- + +## CalDAV напоминания + +**Воркер:** `cal_reminder_worker()` — каждые 5 минут проверяет события на ближайшие 15 минут. +**Файл отправленных:** `~/.config/deltabot/cal_reminders_sent.json` — `{uid: timestamp}`, очищается автоматически (старше 25ч). +Уведомления получают все подписчики (как утренний отчёт). + +--- + +## CalDAV — события дня в утреннем отчёте + +Функция `send_daily_status_worker()` вызывает `cal_list_events(days_ahead=1)` и добавляет список событий к отчёту. При ошибке CalDAV — отчёт уходит без раздела событий (warning в лог). + +--- + +## Whisper — транскрипция голосовых сообщений + +**API:** Cloudflare Workers AI `@cf/openai/whisper` +**Триггер:** любое входящее сообщение с `file_mime_type` начинающимся на `audio/` и без текста. +**Функция:** `transcribe_audio(file_path)` → POST бинарного аудио на Cloudflare. +Использует уже имеющиеся `CLOUDFLARE_ACCOUNT_ID` и `CLOUDFLARE_API_TOKEN` из `.env`. + +--- + +## /ai summary + +Субкоманда `/ai summary` — разовый вызов AI без изменения истории. Реализована в `ai_agent.summarize_session(chat_id)`. Берёт последние 20 сообщений, просит модель кратко резюмировать диалог. + +--- + +## AI Chat (Cloudflare Workers AI + OpenRouter fallback) + +**Статус:** работает +**Файлы:** `ai_agent.py`, `~/.config/deltabot/ai_sessions.json` +**Провайдеры:** +- **Cloudflare Workers AI** (основной) — 10,000 нейронов/день, регистрация без карты, работает из России +- **OpenRouter** (fallback) — 50 RPD, при ошибке Cloudflare + +Один запрос = один ответ. + +### Переменные окружения (.env) + +``` +CLOUDFLARE_ACCOUNT_ID=<account_id> +CLOUDFLARE_API_TOKEN=<api_token> +OPENROUTER_API_KEY=sk-or-... # только для fallback +``` + +### Команды + +| Команда | Действие | +|---------|----------| +| `/ai on` | Включить AI (любое сообщение → ответ модели) | +| `/ai off` | Выключить AI | +| `/ai status` | Модель, сообщений, ключ | +| `/model` | Список моделей / выбор модели | +| `/apikey [ключ]` | Установить ключ OpenRouter (для fallback) | + +### Модели + +#### Cloudflare Workers AI + +| ID | Нейронов/ответ | Ответов/день | +|----|---------------|--------------| +| `@cf/qwen/qwen3-30b-a3b-fp8` (по умолчанию) | ~20 | ~500 | +| `@cf/meta/llama-3.3-70b-instruct-fp8-fast` | ~116 | ~86 | +| `@cf/meta/llama-3.1-8b-instruct-fast` | ~20 | ~500 | +| `@cf/meta/llama-4-scout-17b-16e-instruct` | ~40 | ~250 | +| `@cf/deepseek-ai/deepseek-r1-distill-qwen-32b` | ~50 | ~200 | +| `@cf/moonshotai/kimi-k2-instruct` | ~40 | ~250 | +| `@cf/aisingapore/gemma-sea-lion-v4-27b-it` | ~40 | ~250 | + +#### OpenRouter (fallback) + +| ID | Контекст | +|----|----------| +| `deepseek/deepseek-v4-flash:free` | 1M | +| `moonshotai/kimi-k2.6:free` | 262K | +| `minimax/minimax-m2.5:free` | 262K | +| `openrouter/free` | auto-route | + +### Лимиты + +- **Cloudflare:** 10,000 нейронов/день (см. таблицу выше) +- **OpenRouter:** 50 запросов/день, 20 RPM (только при падении Cloudflare) + +### Архитектура + +``` +process_message() + ├── Cloudflare Workers AI (основной) + │ └── при ошибке → OpenRouter (fallback) + └── OpenRouter (если модель начинается не с @cf/) +``` + +### Хранение + +**ai_sessions.json:** `{chat_id: {model, enabled, messages[], api_key}}` +Контекст — последние 20 сообщений. API ключ OpenRouter хранится per-session или в `OPENROUTER_API_KEY` env. diff --git a/ai_agent.py b/ai_agent.py new file mode 100644 index 0000000..2210f6f --- /dev/null +++ b/ai_agent.py @@ -0,0 +1,277 @@ +#!/usr/bin/env python3 +"""AI agent — supports OpenRouter and Cloudflare Workers AI.""" + +import datetime +import json +import logging +import os +import time +from typing import List, Dict + +import requests + +logger = logging.getLogger(__name__) + +SESSIONS_FILE = os.path.expanduser("~/.config/deltabot/ai_sessions.json") +MAX_HISTORY = 50 + +def _system_prompt() -> str: + now = datetime.datetime.now().strftime("%d.%m.%Y %H:%M:%S %Z") + return ( + f"Ты — полезный AI-ассистент в Delta Chat. " + f"Текущее серверное время: {now}. " + f"Отвечай на русском, если не просят иное. " + f"Будь точным, лаконичным и полезным." + ) + +# --- Models --- + +OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions" + +DEFAULT_MODEL = "@cf/qwen/qwen3-30b-a3b-fp8" +FALLBACK_MODEL = "openrouter/free" + +MODELS = { + # Cloudflare Workers AI + "@cf/qwen/qwen3-30b-a3b-fp8": "Qwen 3 30B (Cloudflare, по умолчанию)", + "@cf/meta/llama-3.3-70b-instruct-fp8-fast": "Llama 3.3 70B (Cloudflare)", + "@cf/meta/llama-3.1-8b-instruct-fast": "Llama 3.1 8B (Cloudflare, быстрая)", + "@cf/meta/llama-4-scout-17b-16e-instruct": "Llama 4 Scout 17B (Cloudflare)", + "@cf/deepseek-ai/deepseek-r1-distill-qwen-32b": "DeepSeek R1 Distill Qwen 32B (Cloudflare)", + "@cf/moonshotai/kimi-k2-instruct": "Kimi K2 (Cloudflare)", + "@cf/aisingapore/gemma-sea-lion-v4-27b-it": "Gemma Sea Lion v4 27B (Cloudflare)", + # OpenRouter (fallback) + "openrouter/free": "OpenRouter Free (auto-route)", + "deepseek/deepseek-v4-flash:free": "DeepSeek V4 Flash (1M ctx, free)", + "moonshotai/kimi-k2.6:free": "Kimi K2.6 (262K ctx, free)", + "minimax/minimax-m2.5:free": "MiniMax M2.5 (262K ctx, free)", +} + +# --- Session persistence --- + + +def _load(): + if os.path.exists(SESSIONS_FILE): + with open(SESSIONS_FILE, "r", encoding="utf-8") as f: + return json.load(f) + return {} + + +def _save(data): + os.makedirs(os.path.dirname(SESSIONS_FILE), exist_ok=True) + with open(SESSIONS_FILE, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=2) + + +def load_session(chat_id: str) -> Dict: + sessions = _load() + if chat_id not in sessions: + sessions[chat_id] = { + "model": DEFAULT_MODEL, + "enabled": False, + "messages": [], + "api_key": "", + } + _save(sessions) + return sessions[chat_id] + + +def save_session(chat_id: str, session: Dict): + sessions = _load() + sessions[chat_id] = session + _save(sessions) + + +def is_ai_enabled(chat_id: str) -> bool: + return load_session(chat_id).get("enabled", False) + + +def get_api_key(chat_id: str) -> str: + session = load_session(chat_id) + if session.get("api_key"): + return session["api_key"] + return os.environ.get("OPENROUTER_API_KEY", "") + + +# --- Provider detection --- + + +def _is_cloudflare(model: str) -> bool: + return model.startswith("@cf/") + + +# --- Cloudflare Workers AI --- + + +def _call_cloudflare(model: str, messages: List[Dict]) -> str: + account_id = os.environ.get("CLOUDFLARE_ACCOUNT_ID") + api_token = os.environ.get("CLOUDFLARE_API_TOKEN") + if not account_id: + return "❌ CLOUDFLARE_ACCOUNT_ID не установлен в .env" + if not api_token: + return "❌ CLOUDFLARE_API_TOKEN не установлен в .env" + + url = f"https://api.cloudflare.com/client/v4/accounts/{account_id}/ai/run/{model}" + headers = { + "Authorization": f"Bearer {api_token}", + "Content-Type": "application/json", + } + body = {"messages": messages, "max_tokens": 8192} + + timeout = 180 if "deepseek" in model else 60 + + for attempt in range(3): + try: + resp = requests.post(url, json=body, headers=headers, timeout=timeout) + if resp.status_code == 429: + logger.warning( + "Cloudflare 429 (attempt %d/3): %s", attempt + 1, resp.text[:300] + ) + if attempt < 2: + time.sleep(3) + continue + return "⚠️ Лимит Cloudflare AI (429). Попробуй позже." + if resp.status_code != 200: + return f"❌ Cloudflare API ошибка: {resp.status_code}" + + try: + data = resp.json() + if not data.get("success"): + err = data.get("errors", [{}])[0].get("message", resp.text[:200]) + return f"❌ Cloudflare: {err}" + raw = data["result"]["response"] + if isinstance(raw, dict): + raw = raw.get("content", str(raw)) + return raw + except Exception: + return f"❌ Не удалось распарсить ответ Cloudflare: {resp.text[:200]}" + + except requests.Timeout: + if attempt < 2: + logger.warning("Cloudflare timeout (attempt %d/3), retrying...", attempt + 1) + time.sleep(3) + continue + return "⚠️ Таймаут Cloudflare AI. Попробуй позже." + except Exception as e: + return f"❌ Ошибка Cloudflare: {e}" + + return "⚠️ Не удалось получить ответ Cloudflare." + + +# --- OpenRouter --- + + +def _call_openrouter(model: str, messages: List[Dict], api_key: str) -> str: + if not api_key: + return "❌ API ключ не установлен. Используй /apikey [ключ]" + + for attempt in range(3): + try: + resp = requests.post( + OPENROUTER_URL, + json={ + "model": model, + "messages": messages, + "temperature": 0.7, + "max_tokens": 2048, + }, + headers={ + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + }, + timeout=60, + ) + if resp.status_code == 429: + body = resp.text[:300] + retry = resp.headers.get("Retry-After", "?") + limit = resp.headers.get("X-RateLimit-Limit", "?") + remaining = resp.headers.get("X-RateLimit-Remaining", "?") + reset = resp.headers.get("X-RateLimit-Reset", "?") + logger.warning( + "OpenRouter 429 (attempt %d/3): retry=%s limit=%s remaining=%s reset=%s body=%s", + attempt + 1, retry, limit, remaining, reset, body, + ) + if attempt < 2: + time.sleep(3) + continue + return "⚠️ Лимит запросов (429). Попробуй позже или смени модель (/model)." + if resp.status_code != 200: + return f"❌ API ошибка: {resp.status_code}" + break + except requests.Timeout: + if attempt < 2: + logger.warning("OpenRouter timeout (attempt %d/3), retrying...", attempt + 1) + time.sleep(3) + continue + return "⚠️ Таймаут API. Попробуй позже." + except Exception as e: + return f"❌ Ошибка: {e}" + + try: + data = resp.json() + return data["choices"][0]["message"]["content"] + except Exception: + return "❌ Не удалось распарсить ответ API." + + +# --- Main --- + + +def summarize_session(chat_id: str) -> str: + session = load_session(chat_id) + messages = session.get("messages", []) + if not messages: + return "История пуста — нечего резюмировать." + model = session.get("model", DEFAULT_MODEL) + context = messages[-20:] + dialog = "\n".join( + f"{'Пользователь' if m['role'] == 'user' else 'AI'}: {m['content']}" + for m in context + ) + summary_prompt = ( + "Кратко (3–7 предложений) резюмируй следующий диалог на русском языке. " + "Выдели главные темы и итоги:\n\n" + dialog + ) + api_messages = [ + {"role": "system", "content": _system_prompt()}, + {"role": "user", "content": summary_prompt}, + ] + if _is_cloudflare(model): + reply = _call_cloudflare(model, api_messages) + if (reply.startswith("❌") or reply.startswith("⚠️")) and get_api_key(chat_id): + logger.warning("Cloudflare summarization failed, fallback to OpenRouter: %s", reply) + reply = _call_openrouter(FALLBACK_MODEL, api_messages, get_api_key(chat_id)) + return reply + return _call_openrouter(model, api_messages, get_api_key(chat_id)) + + +def process_message(chat_id: str, user_message: str) -> str: + session = load_session(chat_id) + model = session.get("model", DEFAULT_MODEL) + + messages = session.get("messages", []) + messages.append({"role": "user", "content": user_message}) + + if len(messages) > MAX_HISTORY: + messages = messages[-MAX_HISTORY:] + + api_messages = [{"role": "system", "content": _system_prompt()}] + messages + + if _is_cloudflare(model): + reply = _call_cloudflare(model, api_messages) + if (reply.startswith("❌") or reply.startswith("⚠️")) and get_api_key(chat_id): + cf_error = reply + logger.warning("Cloudflare failed, fallback to OpenRouter: %s", cf_error) + reply = _call_openrouter(FALLBACK_MODEL, api_messages, get_api_key(chat_id)) + else: + api_key = get_api_key(chat_id) + reply = _call_openrouter(model, api_messages, api_key) + + if reply.startswith("❌") or reply.startswith("⚠️"): + return reply + + messages.append({"role": "assistant", "content": reply}) + session["messages"] = messages + save_session(chat_id, session) + + return reply diff --git a/deltabot.py b/deltabot.py new file mode 100644 index 0000000..23274c7 --- /dev/null +++ b/deltabot.py @@ -0,0 +1,2418 @@ +#!/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(): + 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 + subscribers = load_subscribers() + 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}" + for accid, chat_id in list(subscribers): + 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() \ No newline at end of file diff --git a/deltabot.service b/deltabot.service new file mode 100644 index 0000000..d9f2ff6 --- /dev/null +++ b/deltabot.service @@ -0,0 +1,15 @@ +[Unit] +Description=Delta Chat Bot +After=network.target + +[Service] +Type=simple +User=alexabudaev +WorkingDirectory=/home/alexabudaev/delta-bot +EnvironmentFile=/home/alexabudaev/.config/deltabot/.env +ExecStart=/home/alexabudaev/delta-bot/.venv/bin/python deltabot.py +Restart=always +RestartSec=10 + +[Install] +WantedBy=multi-user.target \ No newline at end of file diff --git a/php-proxy/.htaccess b/php-proxy/.htaccess new file mode 100644 index 0000000..30fd82c --- /dev/null +++ b/php-proxy/.htaccess @@ -0,0 +1,17 @@ +Options -Indexes +RewriteEngine On + +# Allow only bot IP +RewriteCond %{REMOTE_ADDR} !^90\.188\.48\.201$ +RewriteRule ^ - [F,L] + +# Nice URLs: /rss/gremtelegram -> rss.php?channel=gremtelegram +RewriteRule ^rss/([a-zA-Z0-9_]+)$ rss.php?channel=$1 [L,QSA] + +# Block direct access to cache directory +RewriteRule ^cache/ - [F,L] + +# Cache images for 7 days +<FilesMatch "\.(jpg|jpeg|png|gif|webp)$"> + Header set Cache-Control "public, max-age=604800, immutable" +</FilesMatch> diff --git a/php-proxy/SETUP.md b/php-proxy/SETUP.md new file mode 100644 index 0000000..47687e8 --- /dev/null +++ b/php-proxy/SETUP.md @@ -0,0 +1,58 @@ +# PHP-Proxy для Telegram RSS + +Архитектура: +``` +tg.i-c-a.su (10-17s) → proxy.budaev.org (PHP + кэш) → бот (<0.1s) +``` + +## Установка + +### 1. Загрузить файлы на хостинг + +Через FTP залить всё содержимое `php-proxy/` в корень домена `proxy.budaev.org`: +``` +/home/USER/www/proxy.budaev.org/ + .htaccess + rss.php + media.php + channels.json +``` + +### 2. Прогреть кэш + +Открыть в браузере (каждый запрос ~10-17с): +``` +https://proxy.budaev.org/rss/gremtelegram +https://proxy.budaev.org/rss/raiznews +https://proxy.budaev.org/rss/droidergram +https://proxy.budaev.org/rss/mkvburyatii +https://proxy.budaev.org/rss/markettwits +``` + +### 3. Обновить deltabot.py + +Изменения уже сделаны локально. Задеплоить на сервер: +``` +scp deltabot.py SERVER:~/delta-bot/ && ssh SERVER sudo systemctl restart deltabot +``` + +## Как это работает + +- **Без cron** — кэш наполняется лениво, по первому запросу. Крон не нужен. +- **`rss.php`** — отдаёт RSS из кэша (TTL 600с). При кэш-миссе проксирует с tg.i-c-a.su, кэширует. +- **`media.php`** — отдаёт картинки из кэша (TTL 24ч). При кэш-миссе скачивает и кэширует. +- **Бот** — стучится на `rss.php` как на обычный RSS-прокси. Ничего не знает про кэш. + +## Добавление нового канала + +После `/channels add username`: +1. Первый poll — медленный (10-17с, `rss.php` проксирует с tg.i-c-a.su) +2. Со второго — мгновенно из кэша +3. Картинка первого поста — тоже медленная, дальше мгновенно + +Можно сразу прогреть вручную: открыть `https://proxy.budaev.org/rss/username`. + +## Важно + +- Не забудь добавить username в `channels.json` (для мониторинга, не влияет на работу) +- Картинки живут в кэше 24ч, потом перезапрашиваются diff --git a/php-proxy/channels.json b/php-proxy/channels.json new file mode 100644 index 0000000..154fafd --- /dev/null +++ b/php-proxy/channels.json @@ -0,0 +1,8 @@ +[ + "markettwits", + "raiznews", + "droidergram", + "gremtelegram", + "postnauka", + "kartiny2" +] diff --git a/php-proxy/media.php b/php-proxy/media.php new file mode 100644 index 0000000..def253d --- /dev/null +++ b/php-proxy/media.php @@ -0,0 +1,120 @@ +<?php +/** + * Telegram Media Proxy with cache + * + * GET /media.php?url=https://tg.i-c-a.su/media/CHANNEL/POSTID/HASH.ext + * + * Returns cached image or proxies from tg.i-c-a.su. + * Old cache files are cleaned up probabilistically (~2% of requests). + */ + +define('IMG_DIR', __DIR__ . '/cache/img'); +define('TG_BASE', 'https://tg.i-c-a.su'); +define('UA', 'Mozilla/5.0 (compatible; ProxyBot/1.0)'); +define('MEDIA_TTL', 86400); + +$url = $_GET['url'] ?? ''; +if (!$url || strpos($url, TG_BASE) !== 0) { + header('HTTP/1.1 400 Bad Request'); + echo 'Invalid URL'; + exit; +} + +if (!is_dir(IMG_DIR)) mkdir(IMG_DIR, 0755, true); + +// Determine cache path from URL +$path = parse_url($url, PHP_URL_PATH); +$basename = basename($path); +if (!preg_match('/\.\w+$/', $basename)) { + $basename .= '.jpg'; +} +$cacheFile = IMG_DIR . '/' . $basename; + +// Content type map +$extToType = [ + 'jpg' => 'image/jpeg', + 'jpeg' => 'image/jpeg', + 'png' => 'image/png', + 'gif' => 'image/gif', + 'webp' => 'image/webp', +]; +$ext = strtolower(pathinfo($basename, PATHINFO_EXTENSION)); +$contentType = $extToType[$ext] ?? 'image/jpeg'; + +// Periodic cleanup (~2% of requests) +maybeCleanOldCache(); + +// Serve from cache if fresh +if (file_exists($cacheFile) && filemtime($cacheFile) > time() - MEDIA_TTL) { + header('Content-Type: ' . $contentType); + header('Content-Length: ' . filesize($cacheFile)); + header('Cache-Control: public, max-age=' . MEDIA_TTL); + header('X-Cache: HIT'); + readfile($cacheFile); + exit; +} + +// Fetch from tg.i-c-a.su +$ctx = stream_context_create(['http' => ['timeout' => 60, 'user_agent' => UA, 'follow_location' => true]]); +$data = @file_get_contents($url, false, $ctx); + +if (!$data) { + header('HTTP/1.1 502 Bad Gateway'); + echo 'Failed to fetch media'; + exit; +} + +// Verify it's actually an image before caching +$imageSignatures = [ + "\xff\xd8\xff", // JPEG + "\x89\x50\x4e\x47", // PNG + "GIF87a", // GIF + "GIF89a", // GIF + "RIFF", // WebP +]; +$isImage = false; +foreach ($imageSignatures as $sig) { + if (strncmp($data, $sig, strlen($sig)) === 0) { + $isImage = true; + break; + } +} + +if (!$isImage) { + header('HTTP/1.1 415 Unsupported Media Type'); + header('Content-Type: text/plain'); + echo 'Not an image'; + exit; +} + +// Save to cache +file_put_contents($cacheFile, $data); + +// Return +header('Content-Type: ' . $contentType); +header('Content-Length: ' . strlen($data)); +header('Cache-Control: public, max-age=' . MEDIA_TTL); +header('X-Cache: MISS'); +echo $data; + +// Clean files older than TTL, runs ~2% of requests +function maybeCleanOldCache(): void { + if (rand(1, 50) !== 1) return; + $dir = IMG_DIR; + if (!is_dir($dir)) return; + $cutoff = time() - MEDIA_TTL; + $imgExts = ['jpg', 'jpeg', 'png', 'gif', 'webp']; + $dh = opendir($dir); + if (!$dh) return; + while (($f = readdir($dh)) !== false) { + if ($f === '.' || $f === '..') continue; + $fp = $dir . '/' . $f; + if (!is_file($fp)) continue; + $ext = strtolower(pathinfo($fp, PATHINFO_EXTENSION)); + // Remove non-image files and files older than TTL + if (!in_array($ext, $imgExts) || filemtime($fp) < $cutoff) { + unlink($fp); + } + } + closedir($dh); +} diff --git a/php-proxy/rss.php b/php-proxy/rss.php new file mode 100644 index 0000000..dc8267f --- /dev/null +++ b/php-proxy/rss.php @@ -0,0 +1,99 @@ +<?php +/** + * Telegram RSS Proxy with cache + * + * GET /rss.php?channel=CHANNEL&limit=N + * or via .htaccess rewrite: /rss/CHANNEL?limit=N + * + * Returns RSS XML from local cache (if fresh) or proxies from tg.i-c-a.su. + * Enclosure URLs are rewritten to go through media.php for local caching. + * Cache files are pre-rewritten so cache hits avoid DOMDocument entirely. + */ + +define('CACHE_DIR', __DIR__ . '/cache'); +define('TG_BASE', 'https://tg.i-c-a.su'); +define('PROXY_BASE', 'https://proxy.budaev.org'); +define('UA', 'Mozilla/5.0 (compatible; ProxyBot/1.0)'); +define('CACHE_TTL', 600); + +$channel = preg_replace('/[^a-zA-Z0-9_]/', '', $_GET['channel'] ?? ''); +if (!$channel) { + header('HTTP/1.1 400 Bad Request'); + echo 'Missing channel parameter'; + exit; +} + +$limit = min(max((int)($_GET['limit'] ?? 10), 1), 100); + +// Staggered TTL: vary per channel to avoid mass expiration +$stagger = crc32($channel) % 300; +$ttl = CACHE_TTL + $stagger; + +// Non-standard limits: fetch directly, no caching +if ($limit !== 10) { + $xml = fetchFeed($channel, $limit, true); + if ($xml === null) { + header('HTTP/1.1 502 Bad Gateway'); + echo 'Failed to fetch feed'; + exit; + } + header('Content-Type: application/rss+xml; charset=utf-8'); + echo $xml; + exit; +} + +$cacheFile = CACHE_DIR . '/' . $channel . '.xml'; + +// Cache hit — pre-rewritten, no DOMDocument needed +if (file_exists($cacheFile) && filemtime($cacheFile) > time() - $ttl) { + header('Content-Type: application/rss+xml; charset=utf-8'); + readfile($cacheFile); + exit; +} + +// Cache miss or stale — fetch and rewrite in one DOMDocument pass +$xml = fetchFeed($channel, $limit, false); +if ($xml === null) { + // Serve stale cache if available + if (file_exists($cacheFile)) { + header('Content-Type: application/rss+xml; charset=utf-8'); + readfile($cacheFile); + exit; + } + // No cache at all — return empty feed so bot doesn't fall back to tg.i-c-a.su + header('Content-Type: application/rss+xml; charset=utf-8'); + echo '<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"><channel><title>' . htmlspecialchars($channel) . ''; + exit; +} + +// Save pre-rewritten XML to cache +file_put_contents($cacheFile, $xml); + +header('Content-Type: application/rss+xml; charset=utf-8'); +echo $xml; + + +function fetchFeed(string $channel, int $limit, bool $skipRewrite): ?string { + $url = TG_BASE . '/rss/' . $channel . '?limit=' . $limit; + $ctx = stream_context_create(['http' => ['timeout' => 30, 'user_agent' => UA, 'follow_location' => true]]); + $raw = @file_get_contents($url, false, $ctx); + if (!$raw) return null; + + if ($skipRewrite) { + return $raw; + } + + // Parse DOM once, rewrite enclosures, return pre-rewritten XML + $doc = new DOMDocument(); + @$doc->loadXML($raw); + if (!$doc->documentElement) return null; + + foreach ($doc->getElementsByTagName('enclosure') as $enc) { + $encUrl = $enc->getAttribute('url'); + if (strpos($encUrl, TG_BASE) !== false) { + $enc->setAttribute('url', PROXY_BASE . '/media.php?url=' . urlencode($encUrl)); + } + } + + return $doc->saveXML(); +}