Initial commit: delta-chat-bot
This commit is contained in:
commit
8f47610133
10 changed files with 3603 additions and 0 deletions
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
__pycache__/
|
||||
.DS_Store
|
||||
*.bak
|
||||
*.bak.*
|
||||
587
AGENTS.MD
Normal file
587
AGENTS.MD
Normal file
|
|
@ -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=<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 <username> [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|*@domain>`** — добавить 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 сохраняется в кэш уже с переписанными `<enclosure url>` (на 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 `<rss><channel><title>...</title></channel></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`) добавляет `<enclosure url="..." type="image/jpeg" length="..."/>` в RSS для постов с медиа. Бот:
|
||||
1. Читает первый `<enclosure>` из RSS
|
||||
2. Если `type` начинается с `image/` — скачивает эту картинку
|
||||
3. Прикрепляет к тексту поста (поле `file` в `send_msg`)
|
||||
4. Удаляет временный файл после отправки
|
||||
5. Если скачать не удалось — пост уходит без картинки
|
||||
|
||||
### HTML → plain text
|
||||
|
||||
Delta Chat не рендерит Markdown. Из HTML вырезаются все теги:
|
||||
|
||||
| Тег в RSS | Результат |
|
||||
|-----------|-----------|
|
||||
| `<br>` | перенос строки |
|
||||
| `<a href="url">text</a>` | сохраняется только `text`, URL → сноска |
|
||||
| `<a href="*max.ru*">...</a>` | URL не попадает в сноску, текст сохраняется |
|
||||
| Сноска | `---\nurl1\nurl2` |
|
||||
| `<b>`, `<i>`, `<blockquote>` и т.д. | удаляются |
|
||||
| `<tg-emoji>` / `<a><img>` | удаляются |
|
||||
| HTML entities | `html.unescape()` |
|
||||
| `<title>` | 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.
|
||||
277
ai_agent.py
Normal file
277
ai_agent.py
Normal file
|
|
@ -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
|
||||
2418
deltabot.py
Normal file
2418
deltabot.py
Normal file
File diff suppressed because it is too large
Load diff
15
deltabot.service
Normal file
15
deltabot.service
Normal file
|
|
@ -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
|
||||
17
php-proxy/.htaccess
Normal file
17
php-proxy/.htaccess
Normal file
|
|
@ -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>
|
||||
58
php-proxy/SETUP.md
Normal file
58
php-proxy/SETUP.md
Normal file
|
|
@ -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ч, потом перезапрашиваются
|
||||
8
php-proxy/channels.json
Normal file
8
php-proxy/channels.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
[
|
||||
"markettwits",
|
||||
"raiznews",
|
||||
"droidergram",
|
||||
"gremtelegram",
|
||||
"postnauka",
|
||||
"kartiny2"
|
||||
]
|
||||
120
php-proxy/media.php
Normal file
120
php-proxy/media.php
Normal file
|
|
@ -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);
|
||||
}
|
||||
99
php-proxy/rss.php
Normal file
99
php-proxy/rss.php
Normal file
|
|
@ -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) . '</title></channel></rss>';
|
||||
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();
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue