delta-chat-bot/AGENTS.MD
2026-06-13 15:53:28 +08:00

603 lines
33 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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

# Delta Chat Bot: deltabot
## ⚠️ Важно: как мы работаем
- Я работаю на **локальном Mac** (`/Users/alexabudaev/Documents/Zed/`), не на сервере.
- Все изменения — только локально. После завершения работы — `git push origin main`.
- **Репозиторий:** `https://git.budaev.org/alexabudaev/delta-chat-bot`
- **Авторизация:** HTTPS + токен (credentials в `~/.config/git/forgejo-credentials`)
- **Сервер:** Ubuntu 24.04, бот под `alexabudaev`, Postfix/Dovecot под `bot`.
### Авто-деплой на сервер
На сервере настроен systemd timer — каждые 5 минут проверяет обновления в `main` и перезапускает бота:
```bash
sudo systemctl status deltabot-auto-update.timer
```
Принудительный деплой (если надо прямо сейчас):
```bash
ssh alexabudaev@10.0.0.1 "cd ~/delta-bot && git pull origin main && sudo systemctl restart deltabot"
```
### Прокси (PHP)
После изменений в `php-proxy/``scp` вручную (пока не в git на сервере):
```bash
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/
```
## Архитектура
```
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
# После изменений — просто пуш:
git push origin main
# На сервере авто-деплой каждые 5 мин. Принудительно:
ssh alexabudaev@10.0.0.1 "cd ~/delta-bot && git pull origin main && sudo systemctl restart deltabot"
# Логи:
ssh alexabudaev@10.0.0.1 "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-посредник. Кэш , лимит 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` 90180с.
- **`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` 18090с интервал уменьшен для устранения пропусков в часто обновляемых каналах.
- **`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.