Initial commit: delta-chat-bot

This commit is contained in:
Алексей Будаев 2026-06-13 15:53:05 +08:00
commit 8f47610133
10 changed files with 3603 additions and 0 deletions

4
.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
__pycache__/
.DS_Store
*.bak
*.bak.*

587
AGENTS.MD Normal file
View 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
View 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 = (
"Кратко (37 предложений) резюмируй следующий диалог на русском языке. "
"Выдели главные темы и итоги:\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

File diff suppressed because it is too large Load diff

15
deltabot.service Normal file
View 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
View 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
View 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
View file

@ -0,0 +1,8 @@
[
"markettwits",
"raiznews",
"droidergram",
"gremtelegram",
"postnauka",
"kartiny2"
]

120
php-proxy/media.php Normal file
View 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
View 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();
}