Compare commits
119
Commits
rpi-media
...
9a82a6e639
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a82a6e639 | ||
|
|
b2bb921890 | ||
|
|
1dc4705309 | ||
|
|
ba6cc4054a | ||
|
|
6a4c199e47 | ||
|
|
1bf2760973 | ||
|
|
bb5b2c46cd | ||
|
|
3f5f9664e7 | ||
|
|
9bb9b1debf | ||
|
|
3007b43788 | ||
|
|
ebe17db0ba | ||
|
|
7fc58175dd | ||
|
|
6b88f9856f | ||
|
|
5132ef8c63 | ||
|
|
19f957f1c6 | ||
|
|
5e0b13db00 | ||
|
|
6a564df167 | ||
|
|
e38ca91cff | ||
|
|
4eacb09f8e | ||
|
|
71486960c5 | ||
|
|
8a06244ef4 | ||
|
|
8373a87826 | ||
|
|
169978d2d4 | ||
|
|
70b2be3f2a | ||
|
|
be5aa94c18 | ||
|
|
435c1d1a13 | ||
|
|
176913cb15 | ||
|
|
bdc7e5ecc9 | ||
|
|
5c3f94bed8 | ||
|
|
3bdda1068d | ||
|
|
e7499cab3e | ||
|
|
643744a575 | ||
|
|
fdc52ba982 | ||
|
|
d08516d2ab | ||
|
|
85fc5c0bce | ||
|
|
316e1e4af4 | ||
|
|
189b85d3f7 | ||
|
|
8d56e3fa97 | ||
|
|
fc9fe0ccfd | ||
|
|
352d114023 | ||
|
|
9c5c9de412 | ||
|
|
7408e350f5 | ||
|
|
dfb8dffc6f | ||
|
|
0f2a403b99 | ||
|
|
9aab5b3f40 | ||
|
|
8fd84239be | ||
|
|
4b8405040e | ||
|
|
5a16e62745 | ||
|
|
d23be78c0a | ||
|
|
f1bef9d2aa | ||
|
|
a65a19d5b2 | ||
|
|
5dcf669200 | ||
|
|
a2ec7229e5 | ||
|
|
fb76b2116b | ||
|
|
3512f4dbef | ||
|
|
2a166b70cb | ||
|
|
498f729620 | ||
|
|
1d4ffff98c | ||
|
|
1de8c11fb8 | ||
|
|
44aaf00227 | ||
|
|
dff068b9e5 | ||
|
|
d832be2ccf | ||
|
|
2703917be4 | ||
|
|
d101f7093a | ||
|
|
b69afd89a1 | ||
|
|
7d31435262 | ||
|
|
f87fcd1f5a | ||
|
|
d28fe9f5be | ||
|
|
1c636c1f0f | ||
|
|
5b2640ee10 | ||
|
|
d96dcf1f64 | ||
|
|
a4aee69170 | ||
|
|
9c589936e6 | ||
|
|
ed1ea6d207 | ||
|
|
1415aa37d4 | ||
|
|
b2df510a96 | ||
|
|
6dfb1394f9 | ||
|
|
6ef28a7621 | ||
|
|
2ea676eee8 | ||
|
|
8548ea0bb4 | ||
|
|
72ad013505 | ||
|
|
b44a9e6fb2 | ||
|
|
7ef4d0b3e5 | ||
|
|
89c9563453 | ||
|
|
4775d59a4e | ||
|
|
5c17e4f3d6 | ||
|
|
3ecc432ab4 | ||
|
|
cc363aa714 | ||
|
|
5b60da731b | ||
|
|
61b38b6886 | ||
|
|
d5bff58e2a | ||
|
|
3a87905764 | ||
|
|
b682f4db3c | ||
|
|
61e191fabf | ||
|
|
d2283f0d77 | ||
|
|
5cc0d468ef | ||
|
|
587ae5ce7a | ||
|
|
a879988cb0 | ||
|
|
587a57a60c | ||
|
|
aece60da74 | ||
|
|
258367d22d | ||
|
|
e2bd908198 | ||
|
|
3159921a4d | ||
|
|
2f9f222fac | ||
|
|
436e855f33 | ||
|
|
0de0132eef | ||
|
|
2cac56073e | ||
|
|
d2a7b8efbb | ||
|
|
fc59f7ac39 | ||
|
|
e1e8c45a93 | ||
|
|
78f34c97eb | ||
|
|
b982f63b24 | ||
|
|
4ed2a16499 | ||
|
|
1287a857cf | ||
|
|
b34e831fef | ||
|
|
a360743dc7 | ||
|
|
618f789ec0 | ||
|
|
b8a31c7e54 | ||
|
|
716539def3 |
@@ -2,6 +2,9 @@
|
|||||||
|
|
||||||
Описание состояния серверов.
|
Описание состояния серверов.
|
||||||
|
|
||||||
|
## Задачи
|
||||||
|
Открытые задачи по всей инфре — [`TODO.md`](TODO.md) (кто делает: владелец или Astra).
|
||||||
|
|
||||||
## Серверы
|
## Серверы
|
||||||
- [`servers/de1.md`](servers/de1.md) — 213.136.74.247. **Выведен** (все сервисы остановлены, метрики удалены): был прокси/VPN-нодой (telemt, AmneziaWG, OpenVPN) плюс gitea, openclaw, боты — всё уехало на fr1 или в бэкапы. ⚠️ Сам файл описания под вывод не переписан.
|
- [`servers/de1.md`](servers/de1.md) — 213.136.74.247. **Выведен** (все сервисы остановлены, метрики удалены): был прокси/VPN-нодой (telemt, AmneziaWG, OpenVPN) плюс gitea, openclaw, боты — всё уехало на fr1 или в бэкапы. ⚠️ Сам файл описания под вывод не переписан.
|
||||||
- [`servers/fr1.md`](servers/fr1.md) — `fr1.ruzzy.dev` (161.97.93.252). Основная нода (Ubuntu 24.04): мониторинг, gitea, AmneziaWG exit для ru1, боты (claude-channels, openclaw), статика `www`/`share`. Держит апекс `ruzzy.dev`.
|
- [`servers/fr1.md`](servers/fr1.md) — `fr1.ruzzy.dev` (161.97.93.252). Основная нода (Ubuntu 24.04): мониторинг, gitea, AmneziaWG exit для ru1, боты (claude-channels, openclaw), статика `www`/`share`. Держит апекс `ruzzy.dev`.
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
# TODO
|
||||||
|
|
||||||
|
Открытые задачи по инфре. Подробности и контекст — в `servers/<хост>.md` («Открытые хвосты»).
|
||||||
|
Сделал — вычеркни (`[x]`) с датой или удали пункт, а факт перенеси в паспорт хоста.
|
||||||
|
Исполнитель: **владелец** — нужны руки/пароли владельца; **Astra** — может сделать агент на rpi.
|
||||||
|
|
||||||
|
_Собрано 2026-10-11._
|
||||||
|
|
||||||
|
## 🟠 Важно
|
||||||
|
|
||||||
|
- [x] (2026-10-13: `onedrive-backup` и `router-backup` — success; OneDrive после фильтра malware-detected прошёл 13.10 08:37) **Astra** — Проверить, что закончились первая выгрузка OneDrive (~368 ГБ) и первичная копия на диск роутера.
|
||||||
|
⚠️ Копия на роутер идёт медленно (3–8 МБ/с) — может сработать ложный `BackupNeverReported{job="backup_router"}` (30 ч).
|
||||||
|
|
||||||
|
## 🟡 По желанию
|
||||||
|
|
||||||
|
- [ ] **владелец** — Пароль администратора D‑Link (логин `Admin`) — тогда скилл `router` покажет, какой клиент
|
||||||
|
к какой точке mesh (M15 №1/№2, E15) подключён, и карта сети станет точной.
|
||||||
|
- [ ] **Astra** — `STATUS.txt` в `router:rpi-backup/` после каждой копии: даты последних успешных sync OneDrive,
|
||||||
|
бэкапов серверов и самой копии — чтобы при восстановлении с роутера было видно, насколько данные свежие.
|
||||||
|
- [ ] **Astra** — Разово забрать на rpi `~rus/backups` (5.4 ГБ, архивы de1) и `~tgclaude/backups` (4.2 ГБ, снимок Gitea
|
||||||
|
перед апгрейдом) с fr1; после проверки — удалить их с fr1 (решение владельца).
|
||||||
|
- [ ] **владелец** — `codex-remote.service` на rpi: QR‑сопряжение ChatGPT с Linux не работает — убрать службу или ждать
|
||||||
|
поддержки от OpenAI.
|
||||||
|
- [ ] **владелец** — Перевыпустить токен бота @rasp_contol_bot (`/revoke` у @BotFather) — он проходил через чат с
|
||||||
|
ассистентом; новый вписать в `/etc/astra-bot.env`.
|
||||||
|
- [ ] **владелец** — Диск WD Elements 1 ТБ: роль второй копии уже у диска в роутере — либо третья копия через USB‑хаб
|
||||||
|
с питанием, либо забыть.
|
||||||
|
- [ ] **Astra** — Решить про node-exporter на rpi (CPU/RAM/SD малины сейчас не видны в Prometheus).
|
||||||
|
- [ ] **Astra** — Политика хранения входящих фото бота (`~astra/.local/share/astra-bot/incoming/` не чистится).
|
||||||
|
- [ ] **владелец** — Защита ветки `main` в `ruzzy/infra` и `ruzzy/astra-bot` от force-push (обычный push Astra останется).
|
||||||
|
- [ ] **fr1** — Отозвать старые Gitea-токены `imgbot-setup`/`imgbot-setup2` (рецепт удаления через sqlite — в `servers/fr1.md`).
|
||||||
|
- [ ] **fr1** — Пароль Grafana вписан литералом в живой `~/monitoring/docker-compose.yml` — вынести в `.env`, как в репо.
|
||||||
|
|
||||||
|
## 🧩 Новые возможности Astra (разберём позже)
|
||||||
|
|
||||||
|
- [x] 2. Интеграция с двумя календарями — личным и рабочим (2026-10-12: скилл `google`; рабочий — только занятость, см. `servers/rpi.md`)
|
||||||
|
- [x] 3. Скилл для работы с PDF с книгами (2026-10-13: читалка books.ruzzy.dev на fr1, репо `ruzzy/books`, см. `servers/fr1.md`)
|
||||||
|
- [ ] 4. Скилл составления и слежения за планами обучения
|
||||||
|
- [ ] 5. Скилл для собственных проектов
|
||||||
|
- [x] 6. Скилл для ведения собственных дел (2026-10-13: скилл `life`, `Дела насущные/`, см. `servers/rpi.md`)
|
||||||
|
- [ ] 7. Скрипты для работы с чатами — выжимки и слежение за интересующей информацией
|
||||||
|
- [ ] 8. Собрать промежуточные точки доступа (mesh D-Link M15 ×2, E15) и настроить скиллы для других устройств
|
||||||
|
- [ ] 12. Опознать `.46` (`ea:08:6d:8c:59:56`, iOS без имени) и подтвердить, что `.13` — iPhone жены
|
||||||
|
- [ ] 13. Подключить календарь для Полины
|
||||||
|
- [x] 14. Подключить к почте Astra (индекс, классы, метки, ожидаемые письма) остальные ящики владельца —
|
||||||
|
**ruzzyrullezz@gmail.com** подключён 2026-10-16 (см. `servers/rpi.md`, «Второй ящик»); другие ящики не нужны (владелец 2026-10-17).
|
||||||
|
Для Gmail — тот же OAuth-клиент проекта `astra`, второй токен; ⚠️ квота ~100 писем/мин на пользователя — первичная индексация долгая.
|
||||||
|
- [ ] 15. **MCP ко всему этому** — подключать Claude (claude.ai web/Desktop/телефон, Claude Code, Astra) к книгам и прочему.
|
||||||
|
Читалка books.ruzzy.dev: MCP уже есть (`POST /mcp`, инструменты search_books/read_chapter/get_toc/list_books/get_notes/
|
||||||
|
get_recaps, только чтение; сейчас им пользуется только ИИ самой читалки по внутреннему токену) — нужен внешний доступ:
|
||||||
|
Bearer-токены (`books_mcp.py add-token`) для Claude Code/Astra и OAuth 2.1 для custom connector в claude.ai.
|
||||||
|
Дальше — по аналогии для заметок work/life, почты, календаря, ЭлЖура (решить, что из этого стоит открывать).
|
||||||
|
|
||||||
|
## 💼 По работе — новые возможности
|
||||||
|
|
||||||
|
- [ ] **Astra** — Настроить безопасный рабочий контур для взаимодействия с рабочими сервисами
|
||||||
|
(запрос владельца 2026-10-13). Скилл/мониторинг GitLab и оповещатель Linear должны работать из этого контура;
|
||||||
|
подготовка контура — зависимость перед их подключением. При проектировании определить изоляцию,
|
||||||
|
минимальные права, хранение секретов, сетевые доступы и журнал действий; сейчас только запись в плане.
|
||||||
|
|
||||||
|
- [ ] **Astra** — Скилл GitLab и мониторинг активности (запрос владельца 2026-10-13):
|
||||||
|
Работает из безопасного рабочего контура (см. пункт выше).
|
||||||
|
- собирать историю по коммитам;
|
||||||
|
- находить проекты, помогать с их настройкой и анализом, искать реализацию функционала;
|
||||||
|
- уведомлять об успешном/неуспешном завершении пайплайна и зависших пайплайнах;
|
||||||
|
- уведомлять о назначенной владельцу работе (в т.ч. упомянутых «назначенных коммитах»),
|
||||||
|
оставленных комментариях, упоминаниях и других действиях, где от него что-то требуется.
|
||||||
|
Перед реализацией уточнить, что означает «назначенные коммиты» (MR на ревью, задачи или другое),
|
||||||
|
критерий зависшего пайплайна и допустимые изменения проектов. Это запись идеи, GitLab пока не подключать.
|
||||||
|
|
||||||
|
- [ ] **Astra** — Оповещатель Linear (запрос владельца 2026-10-13): уведомлять,
|
||||||
|
когда на владельца назначают задачу, пишут комментарии, меняют статус его задач,
|
||||||
|
упоминают его и происходит другая относящаяся к нему активность.
|
||||||
|
Работает из безопасного рабочего контура (см. пункт выше).
|
||||||
|
Перед реализацией уточнить охват событий и проектов; пока только пункт в плане, без подключения Linear.
|
||||||
|
|
||||||
|
## 📅 По расписанию
|
||||||
|
|
||||||
|
- [ ] **~2026-10-18, Astra** — Снять SMART диска 4 ТБ (`sudo smartctl -A -d sat /dev/sda`), посчитать прирост за неделю
|
||||||
|
Start_Stop (id 4, было 21 946) и Load_Cycle (id 193, было 42 530) → реальный прогноз ресурса, записать в паспорт.
|
||||||
|
- [ ] **2027-01, владелец** — Пробное восстановление бэкапов серверов на Mac (`stacks/server-backup/README.md`):
|
||||||
|
расшифровать свежий архив fr1 и ru1 — проверка и архивов, и ключа.
|
||||||
|
- [ ] **2026-11-01** — Первая ежемесячная перечитка файлов OneDrive с диска роутера (`router-sample`) — убедиться, что прошла.
|
||||||
+41
-11
@@ -15,10 +15,11 @@
|
|||||||
- ⚠️ **Группа `docker` = root-эквивалент.** `docker:x:988:rus,tgclaude` — любой её член монтирует `/` в контейнер и делает что угодно от рута (этим же способом чистились root-овы файлы 2026-09-08). Поэтому «у `tgclaude` нет sudo» **не** означает ограничения прав на хосте.
|
- ⚠️ **Группа `docker` = root-эквивалент.** `docker:x:988:rus,tgclaude` — любой её член монтирует `/` в контейнер и делает что угодно от рута (этим же способом чистились root-овы файлы 2026-09-08). Поэтому «у `tgclaude` нет sudo» **не** означает ограничения прав на хосте.
|
||||||
- Вход с Mac: `ssh rus-161` (алиас в `~/.ssh/config`) → ключ `~/.ssh/id_161_97_93_252` (ed25519).
|
- Вход с Mac: `ssh rus-161` (алиас в `~/.ssh/config`) → ключ `~/.ssh/id_161_97_93_252` (ed25519).
|
||||||
- root-доступ по SSH закрыт; для root изнутри — `sudo -i`.
|
- root-доступ по SSH закрыт; для root изнутри — `sudo -i`.
|
||||||
|
- ~~Пользователь `marusia-tunnel`~~ — удалён 2026-10-11 вместе со всей интеграцией с Марусей (отменена владельцем): пользователь и `/home/marusia-tunnel`, `sshd_config.d/70-marusia-tunnel.conf`, запись в `AllowUsers`, vhost `astra.ruzzy.dev` (`sites-available/astra.ruzzy.dev.conf`, ссылка `zz-…`, `conf.d/marusia-webhook.conf`), сертификат LE `astra.ruzzy.dev`, `~rus/marusia-audit/`. Скрипт демонтажа — `~rus/marusia-removal.sh`, копии удалённого — `/root/marusia-removed-20261011/` (root 700). nginx/sshd перезагружались после успешных `-t`; проверено: git/mon открываются, SSH-вход работает.
|
||||||
- **Пуш-ключ `metrics-push@rpi`** (с 2026-10-11) в `~rus/.ssh/authorized_keys` с `restrict,command="/home/rus/bin/pushgw-ingest rpi"`: этим ключом домашняя малина может только залить метрики в pushgateway — шелла, форвардинга и pty нет. Бэкап прежнего файла — `~/.ssh/authorized_keys.bak-20261011`.
|
- **Пуш-ключ `metrics-push@rpi`** (с 2026-10-11) в `~rus/.ssh/authorized_keys` с `restrict,command="/home/rus/bin/pushgw-ingest rpi"`: этим ключом домашняя малина может только залить метрики в pushgateway — шелла, форвардинга и pty нет. Бэкап прежнего файла — `~/.ssh/authorized_keys.bak-20261011`.
|
||||||
|
|
||||||
## SSH (`/etc/ssh/sshd_config.d/00-hardening.conf`)
|
## SSH (`/etc/ssh/sshd_config.d/00-hardening.conf`)
|
||||||
⚠️ В основном `/etc/ssh/sshd_config` строкой 42 стоит `PermitRootLogin yes` (правка шаблона Contabo). Побеждает всё равно дроп-ин: `Include /etc/ssh/sshd_config.d/*.conf` стоит строкой 12, а sshd берёт **первое** встреченное значение. То есть root-вход закрыт, но держится на порядке строк — уберёшь дроп-ин, и root-вход по ключу включится. ⏳ Рядом лежит `50-cloud-init.conf`, который из-под `rus` не читается: под root стоит проверить `sshd -T | grep -i permitrootlogin`.
|
⚠️ В основном `/etc/ssh/sshd_config` строкой 42 стоит `PermitRootLogin yes` (правка шаблона Contabo). Побеждает всё равно дроп-ин: `Include /etc/ssh/sshd_config.d/*.conf` стоит строкой 12, а sshd берёт **первое** встреченное значение. То есть root-вход закрыт, но держится на порядке строк — уберёшь дроп-ин, и root-вход по ключу включится. Рядом лежит `50-cloud-init.conf` (root 600) — эффективные значения проверены под root 2026-10-11, см. ниже.
|
||||||
|
|
||||||
```
|
```
|
||||||
PermitRootLogin no
|
PermitRootLogin no
|
||||||
@@ -29,7 +30,10 @@ ChallengeResponseAuthentication no
|
|||||||
MaxAuthTries 3
|
MaxAuthTries 3
|
||||||
AllowUsers rus tgclaude
|
AllowUsers rus tgclaude
|
||||||
```
|
```
|
||||||
Через socket-activation (`ssh.socket`).
|
Через socket-activation (`ssh.socket`), слушает `0.0.0.0:22` и `[::]:22`.
|
||||||
|
- `marusia-tunnel` был в `AllowUsers` 2026-10-11 (для туннеля Маруси), убран в тот же день вместе с интеграцией; бэкап исходного — `00-hardening.conf.bak-20261011-171345`.
|
||||||
|
- Проверено под root 2026-10-11: `50-cloud-init.conf` содержит `PasswordAuthentication yes`, но эффективное значение — `no` (`00-hardening` читается первым); `sshd -T` → `permitrootlogin no`, `passwordauthentication no`. ⚠️ То же «держится на порядке файлов»: переименуй `00-hardening` во что-то после `50-`, и вход по паролю включится.
|
||||||
|
- Отпечатки host-ключей: ED25519 `SHA256:M5J9nH+svyA3+beD7ZllUn1tayMMO2cxw/t3Z/kj6KU`, ECDSA `SHA256:fMZEytu7XI3W+Mzo4WQ6WZHDn0nYiagtbKb4JyUIX5w`, RSA `SHA256:53olW/dhoPclFgp8X6OWh0euoOfSmwXWZb6c026OGAw`.
|
||||||
|
|
||||||
## Сеть
|
## Сеть
|
||||||
- Управляется **netplan → systemd-networkd**, конфиг `/etc/netplan/50-cloud-init.yaml` (наследие cloud-init, сейчас просто статический файл).
|
- Управляется **netplan → systemd-networkd**, конфиг `/etc/netplan/50-cloud-init.yaml` (наследие cloud-init, сейчас просто статический файл).
|
||||||
@@ -44,7 +48,7 @@ AllowUsers rus tgclaude
|
|||||||
## DNS
|
## DNS
|
||||||
- Зона `ruzzy.dev` — Namecheap (NS `dns1/dns2.registrar-servers.com`).
|
- Зона `ruzzy.dev` — Namecheap (NS `dns1/dns2.registrar-servers.com`).
|
||||||
- `fr1.ruzzy.dev` A → 161.97.93.252. Апекс `ruzzy.dev` A → 161.97.93.252 (переведён с de1).
|
- `fr1.ruzzy.dev` A → 161.97.93.252. Апекс `ruzzy.dev` A → 161.97.93.252 (переведён с de1).
|
||||||
- CNAME → `fr1.ruzzy.dev`: `git`, `mon`, `www`, `share`.
|
- CNAME → `fr1.ruzzy.dev`: `git`, `mon`, `www`, `share`, `books` (с 2026-10-13).
|
||||||
- ⚠️ `cam.ruzzy.dev` (CNAME → fr1) остался в Namecheap после удаления ipcam 2026-09-08 — снести руками.
|
- ⚠️ `cam.ruzzy.dev` (CNAME → fr1) остался в Namecheap после удаления ipcam 2026-09-08 — снести руками.
|
||||||
- PTR (reverse DNS): дефолтный `vmi3387709.contaboserver.net` (не менялся).
|
- PTR (reverse DNS): дефолтный `vmi3387709.contaboserver.net` (не менялся).
|
||||||
|
|
||||||
@@ -60,13 +64,16 @@ AllowUsers rus tgclaude
|
|||||||
```
|
```
|
||||||
[DEFAULT]
|
[DEFAULT]
|
||||||
bantime=1h findtime=10m maxretry=5
|
bantime=1h findtime=10m maxretry=5
|
||||||
ignoreip = 127.0.0.1/8 ::1 31.153.37.106
|
ignoreip = 127.0.0.1/8 ::1 31.153.37.106 46.199.208.45
|
||||||
backend=systemd banaction=ufw
|
backend=systemd banaction=ufw
|
||||||
|
|
||||||
[sshd] enabled=true
|
[sshd] enabled=true
|
||||||
[recidive] enabled=true bantime=1w findtime=1d maxretry=5
|
[recidive] enabled=true bantime=1w findtime=1d maxretry=5
|
||||||
backend=auto logpath=/var/log/fail2ban.log
|
backend=auto logpath=/var/log/fail2ban.log
|
||||||
```
|
```
|
||||||
|
- `46.199.208.45` — текущий домашний IP (Cyta, за ним rpi и Mac), добавлен 2026-10-11 (было только `31.153.37.106`), бэкап `jail.local.bak-20261011-175800`. Зачем: туннель Маруси с rpi переподключается сам, и серия ошибок входа забанила бы весь дом — вместе с `ssh rus-161` — на час, а `recidive` на неделю.
|
||||||
|
- ⚠️ **Домашний IP динамический** (PPPoE Cyta, см. `servers/rpi.md`). После смены адреса защита молча пропадёт, а в `ignoreip` останется чужой адрес — сверять с `router status` на rpi.
|
||||||
|
- ⏳ Происхождение `31.153.37.106` нигде не записано (стоит с 2026-06-21) — выяснить и убрать, если не нужен.
|
||||||
|
|
||||||
## Обновления
|
## Обновления
|
||||||
- Ubuntu Pro подключён (аккаунт ruzzyrullezz@gmail.com): **livepatch**, **esm-infra**, **esm-apps** — enabled.
|
- Ubuntu Pro подключён (аккаунт ruzzyrullezz@gmail.com): **livepatch**, **esm-infra**, **esm-apps** — enabled.
|
||||||
@@ -100,9 +107,10 @@ backend=systemd banaction=ufw
|
|||||||
## nginx + TLS
|
## nginx + TLS
|
||||||
- nginx 1.24 (apt) + certbot 2.9 (`python3-certbot-nginx`), автопродление через systemd-таймер certbot.
|
- nginx 1.24 (apt) + certbot 2.9 (`python3-certbot-nginx`), автопродление через systemd-таймер certbot.
|
||||||
- vhost `/etc/nginx/sites-available/mon.ruzzy.dev.conf` → проксирует на Grafana (`127.0.0.1:3000`), HTTPS-редирект, websocket.
|
- vhost `/etc/nginx/sites-available/mon.ruzzy.dev.conf` → проксирует на Grafana (`127.0.0.1:3000`), HTTPS-редирект, websocket.
|
||||||
- vhost'ы `share.ruzzy.dev.conf` и `www.ruzzy.dev.conf` — статика с **общим** корнем `/var/www/share.ruzzy.dev` (`autoindex off`, `try_files $uri =404`, `server_tokens off`), у каждого свой сертификат. Используется для раздачи клиентских конфигов OpenVPN — сейчас там `marina-de.ovpn`, `masha-de.ovpn`.
|
- vhost'ы `share.ruzzy.dev.conf` и `www.ruzzy.dev.conf` — статика с **общим** корнем `/var/www/share.ruzzy.dev` (`autoindex off`, `try_files $uri =404`, `server_tokens off`), у каждого свой сертификат. Используется для раздачи клиентских конфигов OpenVPN — сейчас там `marina-de.ovpn`, `masha-de.ovpn`; с 2026-10-12 ещё `astra/index.html` и `astra/privacy.html` — страницы OAuth-приложения Astra для Google (`stacks/www-ruzzy/`, см. `servers/rpi.md`).
|
||||||
- vhost `default` — catch-all `server_name _`, `listen 80 default_server`, отдаёт дефолтную страницу `/var/www/html/index.nginx-debian.html`. На `:443` `default_server` **не задан**, поэтому TLS-запрос с неизвестным SNI попадает на первый по алфавиту vhost (`git.ruzzy.dev`) и получает его сертификат.
|
- vhost `default` — catch-all `server_name _`, `listen 80 default_server`, отдаёт дефолтную страницу `/var/www/html/index.nginx-debian.html`. На `:443` `default_server` **не задан**, поэтому TLS-запрос с неизвестным SNI попадает на первый по алфавиту vhost (`git.ruzzy.dev`) и получает его сертификат. Так отвечают и `https://ruzzy.dev`, и `https://fr1.ruzzy.dev` — своих vhost'ов и сертификатов у них нет, браузер видит несовпадение имени (проверено 2026-10-11).
|
||||||
- Сертификаты LE: `mon`, `git`, `share`, `www`. `cam.ruzzy.dev` удалён 2026-09-08 вместе с ipcam.
|
- vhost `astra.ruzzy.dev` (вебхук Маруси) — **удалён 2026-10-11** вместе с сертификатом. ⚠️ DNS-запись `astra.ruzzy.dev` (CNAME → fr1.ruzzy.dev) у регистратора — удалить вручную; пока она есть, запросы к этому имени отвечает vhost `git` с чужим сертификатом.
|
||||||
|
- Сертификаты LE: `mon`, `git`, `share`, `www`, `books` (с 2026-10-13), `astra` (с 2026-10-11, `certbot certonly --nginx`, продление тем же таймером). `cam.ruzzy.dev` удалён 2026-09-08 вместе с ipcam.
|
||||||
|
|
||||||
## Мониторинг (`~/monitoring`, stack в репо `stacks/monitoring/`)
|
## Мониторинг (`~/monitoring`, stack в репо `stacks/monitoring/`)
|
||||||
- Стек: **Prometheus + Grafana + node-exporter + cAdvisor + Alertmanager + Pushgateway** (docker compose).
|
- Стек: **Prometheus + Grafana + node-exporter + cAdvisor + Alertmanager + Pushgateway** (docker compose).
|
||||||
@@ -135,6 +143,21 @@ backend=systemd banaction=ufw
|
|||||||
- **Пользователь `astra`** (id 5, с 2026-10-11) — агент на домашней малине (`servers/rpi.md`): коллаборатор с записью в `ruzzy/infra` и `ruzzy/astra-bot`, свои репо в `astra/…`, постоянный токен `astra-rpi` (хранится только на малине). Пароль случайный и нигде не сохранён — вход только ключом/токеном; сбросить — `gitea admin user change-password`. Deploy-ключи astra (id 10, 11) удалены.
|
- **Пользователь `astra`** (id 5, с 2026-10-11) — агент на домашней малине (`servers/rpi.md`): коллаборатор с записью в `ruzzy/infra` и `ruzzy/astra-bot`, свои репо в `astra/…`, постоянный токен `astra-rpi` (хранится только на малине). Пароль случайный и нигде не сохранён — вход только ключом/токеном; сбросить — `gitea admin user change-password`. Deploy-ключи astra (id 10, 11) удалены.
|
||||||
- Как сделать разовую операцию через API без следов: `gitea admin user generate-access-token --scopes write:repository --raw` (конфиг по умолчанию верный — работающая Gitea тоже читает `/data/gitea/conf/app.ini`, БД `/data/gitea.db`) → `POST /api/v1/repos/<owner>/<repo>/keys` → удалить токен прямо из БД: `docker exec -u 1000 gitea sqlite3 /data/gitea.db "delete from access_token where name='<имя>'"` (API требует basic-auth, как выяснилось с imgbot-токенами). Проверено 2026-10-11: после удаления токен получает 401.
|
- Как сделать разовую операцию через API без следов: `gitea admin user generate-access-token --scopes write:repository --raw` (конфиг по умолчанию верный — работающая Gitea тоже читает `/data/gitea/conf/app.ini`, БД `/data/gitea.db`) → `POST /api/v1/repos/<owner>/<repo>/keys` → удалить токен прямо из БД: `docker exec -u 1000 gitea sqlite3 /data/gitea.db "delete from access_token where name='<имя>'"` (API требует basic-auth, как выяснилось с imgbot-токенами). Проверено 2026-10-11: после удаления токен получает 401.
|
||||||
|
|
||||||
|
## books.ruzzy.dev — читалка с ИИ (с 2026-10-13, `~/books`, код — репо `ruzzy/books`)
|
||||||
|
- Деплой: из клона `ruzzy/books` (`~/pets/books` на Mac) — `rsync -a --exclude data --exclude .env --exclude .git ./ rus-161:books/ && ssh rus-161 'cd ~/books && docker compose up -d --build'` (подробно — README репо). `~/books` на fr1 — копия, не git-клон.
|
||||||
|
- 2026-10-13: vhost и сертификат LE `books.ruzzy.dev` (до 2027-01-11, продление — таймер certbot) поставил владелец `sudo sh ~/books/nginx/install.sh` (у `rus` sudo по паролю, root-вход закрыт); DNS — CNAME `books` → `fr1.ruzzy.dev` в Namecheap (добавлен в тот же день; ⚠️ wildcard-записи у зоны нет — новый поддомен = новая CNAME, иначе certbot падает с NXDOMAIN). Проверено: https 200, http→https 301, `/` без входа → `/login`, `X-Robots-Tag: noindex`.
|
||||||
|
- 2026-10-13 `CLAUDE_CODE_OAUTH_TOKEN` вписан, проверено на «SRE» (592 с.): индексация 75 с, рекап главы ~8 с, перевод выделенного — первые слова ~5 с (целиком 10–17 с), вопрос с WebSearch ~10 с; у агента «нет инструментов». ⚠️ Токен владелец передал через чат с ассистентом — перевыпустить (`claude setup-token`) и вписать напрямую.
|
||||||
|
- Контейнер `books` (образ `books:local`, compose `~/books/docker-compose.yml`), `127.0.0.1:3010` → nginx vhost `books.ruzzy.dev` (`sites-available/books.ruzzy.dev.conf`, `client_max_body_size 210M`, `proxy_buffering off`, таймаут 900 с — ответы ИИ потоком). Данные — `~/books/data` (uid 1000 = rus): `books.db`, `files/`, `models/`, `claude/`. Лимиты: 4 ГБ RAM, 4 CPU, `cap_drop ALL`, `no-new-privileges`.
|
||||||
|
- ИИ — Claude Code 2.1.197 внутри контейнера в фоновом режиме по **подписке владельца** (`CLAUDE_CODE_OAUTH_TOKEN` в `~/books/.env`, 600), без инструментов; для проверки фактов — только WebSearch. ⚠️ Лимиты подписки общие с claude-channels и работой владельца.
|
||||||
|
- Вход — логин и пароль (решение владельца 2026-10-13, вместо Telegram Login): в `.env` `BOOKS_LOGIN` и scrypt-хеш `BOOKS_PASSWORD_HASH` (`scrypt:соль:хеш`; ⚠️ без `$` — compose подставляет переменные в `.env`, с `$` хеш молча портился и пароль «не подходил»). Не больше 5 неудачных попыток за 15 мин с IP (IP — последний в `X-Forwarded-For`, его ставит nginx) и 30 — всего. Секреты `.env`: `BOOKS_PASSWORD_HASH`, `SESSION_SECRET`, `CLAUDE_CODE_OAUTH_TOKEN`.
|
||||||
|
- 2026-10-13: в образе ещё Go 1.23.4 + goimports + staticcheck (проверка листингов книг; кэш модулей — `~/books/data/go`, качает с proxy.golang.org) и pymupdf4llm. Фоновая подготовка «книжных» глав — Claude по подписке, ~5–10 мин/глава, параллельно ≤2 запроса (`LLM_PARALLEL_BG`), интерактив — отдельно ≤3. Картинки глав — `~/books/data/books/<id>/img/`.
|
||||||
|
- ⚠️ 2026-10-13: Claude Code по умолчанию «размышляет» (thinking) — перевод шёл до минуты; в контейнере `MAX_THINKING_TOKENS=0` (через код, `LLM_THINKING_TOKENS`). Для интерактива держатся 2 прогретых процесса `claude -p --input-format stream-json` (haiku и sonnet, ~200 МБ каждый) — первые слова ответа за ~0,7 с.
|
||||||
|
- **gVisor** (с 2026-10-13): `runsc` release-20261005.0 из apt-репозитория gvisor.dev (`/etc/apt/sources.list.d/gvisor.list`), `/etc/docker/daemon.json` — runtime `runsc` с `--host-uds=create` (файла раньше не было). Подключён `systemctl reload docker` без перезапуска контейнеров (поставил владелец `sudo sh ~/books/runner/gvisor-install.sh`). Используется только `books-runner`. Откат — убрать runtime из daemon.json, reload, `apt-get remove runsc`.
|
||||||
|
- **Песочница `books-runner`** (с 2026-10-13, тот же compose `~/books`): запуск примеров кода из книг. **runtime runsc (gVisor)**, без сети (`network_mode: none`), ФС только для чтения, `cap_drop ALL` + SETUID/SETGID/KILL/CHOWN, 1 CPU / 768 МБ / 128 pid, tmpfs `/work`; код — от nobody, ≤40 процессов, ≤384 МБ (сторож RSS); бинарник собирает контейнер books. ⚠️ Бенчмарки под gVisor в 1,5–2,5 раза медленнее нативных. Связь — unix-сокет `~/books/run/runner.sock`; ⚠️ каталог `~/books/run` — root:rus 0770 (ставился через контейнер — у rus нет sudo без пароля): если сделать 0777, код примеров сможет писать в каталог хоста и стучаться в сокет песочницы.
|
||||||
|
- ⚠️ Агенту книг сознательно не дан доступ к хосту (в отличие от claude-channels с ключами к fr1/gitea): книги и выделения — чужой текст, возможны внедрённые инструкции.
|
||||||
|
- 2026-10-18: в `app/prep.py` логи планировщика (`docker compose logs books | grep prep:` — взял слот, пропустил слот и почему, пауза после лимита, глава готова). Повод: в ночь на 18.10 не подготовилась ни одна глава, причину (вероятно, пауза после лимита в памяти процесса) без логов было не установить; контейнер пересоздан.
|
||||||
|
- 2026-10-18: MCP-токены `bot:…` — для ботов в общих чатах: без `get_notes`, поиск без заметок/стикеров владельца. Выдан `bot:iluha_rabobot` (claude-channels); список — `docker compose exec books python /app/books_mcp.py list`, отзыв — `… revoke bot:iluha_rabobot`.
|
||||||
|
|
||||||
## AmneziaWG exit (для ru1) — перенос VPN, фаза 1
|
## AmneziaWG exit (для ru1) — перенос VPN, фаза 1
|
||||||
- Зеркало exit-узла `de1`: AmneziaWG server `wg0` = **10.8.1.0/24**, ListenPort **36360/udp**, те же server-ключи/PSK и obfuscation-параметры (Jc/Jmin/Jmax/S1/S2/H1-4), что и на `de1` (server-pubkey `Lumb1o…`) → на `ru1` свитч = только смена Endpoint.
|
- Зеркало exit-узла `de1`: AmneziaWG server `wg0` = **10.8.1.0/24**, ListenPort **36360/udp**, те же server-ключи/PSK и obfuscation-параметры (Jc/Jmin/Jmax/S1/S2/H1-4), что и на `de1` (server-pubkey `Lumb1o…`) → на `ru1` свитч = только смена Endpoint.
|
||||||
- Установка: `amneziawg-dkms`+`amneziawg-tools` (PPA amnezia), host-ом через `awg-quick@wg0` (enabled). Конфиг `/etc/amnezia/amneziawg/wg0.conf` (секрет, не в git).
|
- Установка: `amneziawg-dkms`+`amneziawg-tools` (PPA amnezia), host-ом через `awg-quick@wg0` (enabled). Конфиг `/etc/amnezia/amneziawg/wg0.conf` (секрет, не в git).
|
||||||
@@ -150,8 +173,13 @@ backend=systemd banaction=ufw
|
|||||||
- ⚠️ Но том **раздулся заново: 2.8 ГБ** (проверено 2026-09-08) — второй по величине после prometheus_data. Экономия от аккуратного переноса съедена работой самого бота (кэш/история/плагины нарастают внутри контейнера). Если снова упрёмся в диск — смотреть сюда.
|
- ⚠️ Но том **раздулся заново: 2.8 ГБ** (проверено 2026-09-08) — второй по величине после prometheus_data. Экономия от аккуратного переноса съедена работой самого бота (кэш/история/плагины нарастают внутри контейнера). Если снова упрёмся в диск — смотреть сюда.
|
||||||
- ⚠️ В томе, помимо авторизации Claude, лежат **SSH-ключи**: `/root/.ssh/id_ed25519` (в Gitea как deploy-ключ, `Host git.ruzzy.dev`) и `/root/.ssh/astra_fr1` — ключ к самому хосту под `tgclaude` (см. раздел `tgclaude`). Том — чувствительный артефакт, обращаться как с секретом.
|
- ⚠️ В томе, помимо авторизации Claude, лежат **SSH-ключи**: `/root/.ssh/id_ed25519` (в Gitea как deploy-ключ, `Host git.ruzzy.dev`) и `/root/.ssh/astra_fr1` — ключ к самому хосту под `tgclaude` (см. раздел `tgclaude`). Том — чувствительный артефакт, обращаться как с секретом.
|
||||||
- **telegram-плагин переустановлен заново** в контейнере (`claude plugin install telegram@claude-plugins-official`, v0.0.6) — копировать его нельзя.
|
- **telegram-плагин переустановлен заново** в контейнере (`claude plugin install telegram@claude-plugins-official`, v0.0.6) — копировать его нельзя.
|
||||||
- Монтирует `~/MyVault` (`/vault`, 186 МБ). Vault-синк в gitea **работает**: cron у `rus` `*/5 * * * *` → `~/openclaw/sync.sh` — `git pull --rebase --autostash`, затем при изменениях коммит «vault sync <дата>» и push в `ruzzy/MyVault.git`. Ключ `~/.ssh/openclaw_gitea`, лог `~/.openclaw/logs/vault-sync.log`.
|
- **Доступа к Obsidian-vault у бота нет с 2026-10-11** (решение владельца: vault — его личная база, а не рабочая папка агентов; заметки — к Astra на rpi). Убрано: монтирование `/home/rus/MyVault:/vault` из `~/claude-channels/docker-compose.yml` (бэкап `docker-compose.yml.bak-20261011`), разделы «Obsidian Vault»/«Git и Obsidian Vault» в `/root/CLAUDE.md` бота заменены пометкой «доступа нет» (бэкап `/root/CLAUDE.md.bak-20261011`), синк-cron снят, **`~/MyVault` удалён** (227 МБ; перед удалением всё было в gitea, незапушенного не было). История — `ruzzy/MyVault`, архив на 2026-09-23.
|
||||||
|
- Мост `ask-astra` в контейнере (`/root/agent-bridge/bin/ask-astra`) по умолчанию работал в `/vault` — переключён на `/root/astra-work` (пустой git-репозиторий; `ask-astra` требует git). Бэкапы — `ask-astra.bak-20261011`, `astra-section.md.bak-20261011`.
|
||||||
- Gmail MCP — без OAuth-кредов (gmail-инструменты не работают, на бот не влияет).
|
- Gmail MCP — без OAuth-кредов (gmail-инструменты не работают, на бот не влияет).
|
||||||
|
- **MCP `books` с 2026-10-18** (книги читалки, только чтение): `claude mcp add --scope user --transport http books https://books.ruzzy.dev/mcp` с Bearer-токеном `bot:iluha_rabobot` (токен — только в `/root/.claude.json` тома и хешем в `books.db`). В `/root/CLAUDE.md` бота дописан раздел «Книги владельца» (без бэкапа: `cp` в `/root` не прошёл — нет прав; раздел — в конце файла, удаляется целиком): ответы по книгам, публикация в Telegram-канал только по команде владельца, без дословных глав. Контейнер перезапущен (ушёл в новую сессию: прежняя была >5 МБ и заархивирована entrypoint-ом). ⚠️ Текст книг — чужие PDF, теперь он попадает к агенту с SSH-ключами к fr1/gitea и `--dangerously-skip-permissions`: защита от внедрённых инструкций — только правило в CLAUDE.md. Канал для публикаций бот подключает сам по команде владельца «подключи канал @имя» (раздел «Подключение канала» в его CLAUDE.md): `getChat`/`getChatMember` по Bot API → id в `groups` в `/root/.claude/channels/telegram/access.json` (без этого `reply` в канал отклоняется) → строка в «Подключённые каналы» в конце CLAUDE.md. Подключён канал **DulyNotes** (приватный, без @username, id `-1004305290483`, бот — админ с правом публикации) 2026-10-18; бэкап access.json до правки — `/tmp/access.json.bak` в контейнере. ⚠️ У приватного канала `getChat` по имени не работает — id узнавали `getUpdates` при приостановленном (`kill -STOP`) плагине, без `offset`, потом вернули `allowed_updates=[]` (параметр `getUpdates` запоминается Telegram и действует на плагин).
|
||||||
|
- **DulyNotes: разбор книг по расписанию** (с 2026-10-18; с того же дня — **очередь книг**: `books/<slug>.json` — главы, прогресс, `posted`, настройки per_slot/slots/topic/lang; `queue.json` — `{active, queue}`; ставит бот по разговору с владельцем через `pubctl.mjs` (status | add <book_id> [--dry] [--skip] | set | remove | activate); закончилась книга — со следующего слота следующая; неподготовленная читалкой глава — слот пропускается, владельцу уведомление; тексты заметок — `log/<slug>/ch<idx>.html`; прежний `learning-go.json` перенесён, бэкап `learning-go.json.bak-queue`). `/root/publisher/` в томе (создан от root: `/root` в контейнере — root:claude 755, бот сам туда не пишет): `publish.mjs` — в 9:00 и 21:00 по Никосии по 2 главы «Learning Go» (book_id=2, 16 глав → 4 дня) в канал `-1004305290483`, с 2026-10-18 каждая отдельным сообщением как самостоятельная техническая заметка о Go: заголовок по теме, без упоминания книги, автора и глав, связный рассказ с переходом от прошлой заметки (её текст — `log/ch<idx>.html`), 2800–3500 символов; длиннее 3800 — второй проход «сократи», с 2026-10-18 вечера статьи 6000–8500 символов с 5–7 примерами кода (`<pre>` → `<pre><code class="language-go">` — подсветка в Telegram; режется по абзацам, не разрывая код): начало — постом, продолжение — **в комментариях** (группа обсуждений DulyNotesChat `-1004373354142`, бот — админ). Bot API не отдаёт id копии поста в группе, а апдейты держит плагин, поэтому копию ищем перебором: проба-сообщение даёт верхнюю границу id, затем ответ на кандидатов с проверкой `reply_to_message.is_automatic_forward` и `forward_origin.message_id` == id поста, промахи сразу удаляются; копия появляется с задержкой — до 5 попыток с паузой. Не вышло — продолжение ответом в канале. ⚠️ Если в группе удалить копию поста, комментарии под ним пропадают. Ручные режимы: `--fix-comments` (перенести продолжение из канала в комментарии), `--edit-last`, `--repost`; id сообщений — `posted` в `learning-go.json`; `claude -p` запускается из пустого `/tmp/publisher-run`, иначе подхватывает `/root/CLAUDE.md` бота; очередь и сделанные слоты — `learning-go.json`, лог и тексты глав — `log/`. Пост пишет `claude -p` (sonnet, по той же подписке) в **изолированном конфиге** `.claude-config` (только `.credentials.json` — симлинк на основной, пустые settings): без плагинов/хуков/встроенных инструментов, только MCP `books` (`books-mcp.json`, 600, токен `bot:iluha_rabobot`); ⚠️ `MCP_CONNECTION_NONBLOCKING=false` обязателен — иначе MCP «pending» и модель пишет вызов инструмента текстом. ⚠️ Основной конфиг для `claude -p` не годится: поднимется второй telegram-плагин, а он при старте убивает чужой опрос (`server.ts`, «Kill any stale holder»). Отправка — Bot API напрямую; HTML чистится от «<» не по делу, при отказе Telegram — текстом; ошибки — владельцу в личку. Ручные режимы: `--now`, `--preview`.
|
||||||
|
- **Комментарии DulyNotes — ограниченный режим** (с 2026-10-18). Хук `PreToolUse` `/root/.claude/hooks/restricted_guard.js` (в `/root/.claude/settings.json`, бэкап `settings.json.bak-20261018`): если последнее входящее из чата из `/root/.claude/hooks/restricted_chats.json` (`chats`) не от `trusted_users` (607015), разрешены только WebSearch/WebFetch, `mcp__books__*`, reply/react/edit в тот же чат (файлы — только `draw/out` и inbox), Read/Write своей истории, Write `draw/*.txt` и Bash ровно `astra-draw <draw/*.txt>`. Проверено: 24 сценария на поддельных транскриптах + живой `claude -p --dangerously-skip-permissions` (deny соблюдается в bypass). ⚠️ Без транскрипта на диске хук пропускает (у бота он есть всегда; в тестах с `--no-session-persistence` — нет). Группа комментариев DulyNotesChat (`-1004373354142`) подключена 2026-10-18: в `restricted_chats.json` и в `access.json` (`requireMention:false`); бэкап access.json до правки — `/tmp/access.json.bak2` в контейнере. Новые группы подключает бот по команде «подключи комментарии» (раздел в CLAUDE.md; `requireMention:false`, id → `restricted_chats.json`). Privacy mode у бота выключен (`can_read_all_group_messages`), админство в группе не нужно.
|
||||||
|
- **`/root/agent-bridge/bin/astra-draw`** (с 2026-10-18) — Astra только для картинок: `codex exec --sandbox read-only` в пустом temp-каталоге с выключенными shell_tool, unified_exec, apps, plugins, browser_use*, computer_use, multi_agent, goals и `mcp_servers={}`; `code_mode_host` оставлен — через него идёт image_gen. Проверено: прочитать `/etc/hostname` не может (JS без ФС, apply_patch запрещён режимом), дочерний агент тоже без shell, внедрённая в описание просьба прочитать `~/.ssh` проигнорирована. Остаётся `web__run`. Картинки → `/root/agent-bridge/draw/out/`. Бэкап CLAUDE.md бота до этих правок — `/root/agent-bridge/CLAUDE.md.bak-20261018`.
|
||||||
|
|
||||||
## Telegram-уведомления
|
## Telegram-уведомления
|
||||||
- Бот @ultimate_log_bot. Конфиг `/etc/tg-notify.conf` (chmod 600, токен+chat_id — секрет на сервере).
|
- Бот @ultimate_log_bot. Конфиг `/etc/tg-notify.conf` (chmod 600, токен+chat_id — секрет на сервере).
|
||||||
@@ -198,7 +226,7 @@ backend=systemd banaction=ufw
|
|||||||
- `~rus/openclaw` (212 МБ, **не git-репозиторий** — просто распакованное дерево) + `~rus/.openclaw` (2.0 ГБ, из них `agents/` почти всё) + `~rus/openclaw-work` (147 МБ) + `~rus/.openclaw-auth-profile-secrets` — отдельная копия, своих контейнеров не имеет. Единственная живая часть — `sync.sh` из cron `rus` (см. claude-channels), лог пишется.
|
- `~rus/openclaw` (212 МБ, **не git-репозиторий** — просто распакованное дерево) + `~rus/.openclaw` (2.0 ГБ, из них `agents/` почти всё) + `~rus/openclaw-work` (147 МБ) + `~rus/.openclaw-auth-profile-secrets` — отдельная копия, своих контейнеров не имеет. Единственная живая часть — `sync.sh` из cron `rus` (см. claude-channels), лог пишется.
|
||||||
- Образ-снимок `openclaw:backup-20260831` (1.55 ГБ) — путь откатa для openclaw.
|
- Образ-снимок `openclaw:backup-20260831` (1.55 ГБ) — путь откатa для openclaw.
|
||||||
- **`~/backups` — 5.4 ГБ**, всё с уже выведенного `de1`: `openclaw-de1-2026-06-22.tgz` (3.1 ГБ), `claude-channels-de1-2026-06-22.tgz` (1.1 ГБ), `postgres-de1-2026-06-22/` (1.3 ГБ, 11 дампов — крупнейший `pers_data.dump` 1.2 ГБ), `var-www-envs/` (`.env`-ы 8 проектов), 5 pre-wipe-снимков claude-channels от 2026-07-16/18. Кандидат №1 на выселение с сервера, если понадобится место.
|
- **`~/backups` — 5.4 ГБ**, всё с уже выведенного `de1`: `openclaw-de1-2026-06-22.tgz` (3.1 ГБ), `claude-channels-de1-2026-06-22.tgz` (1.1 ГБ), `postgres-de1-2026-06-22/` (1.3 ГБ, 11 дампов — крупнейший `pers_data.dump` 1.2 ГБ), `var-www-envs/` (`.env`-ы 8 проектов), 5 pre-wipe-снимков claude-channels от 2026-07-16/18. Кандидат №1 на выселение с сервера, если понадобится место.
|
||||||
- `~/downloads` — пустой. `~/MyVault` (186 МБ) — см. claude-channels.
|
- `~/downloads` — пустой. `~/MyVault` удалён 2026-10-11 — см. claude-channels.
|
||||||
|
|
||||||
## Выведено с fr1
|
## Выведено с fr1
|
||||||
- **ipcam / FTPS-приёмник** (был `~/ipcam`, `fauria/vsftpd`, порты `2221:21` + `30000-30010`, домен `cam.ruzzy.dev`, данные `/srv/ipcam/videos`, сертификат `/srv/ftps/certs` + deploy-хук certbot) — **удалён 2026-09-08**: контейнер, образ, том `ipcam_ipcam_logs`, сеть, каталоги `/srv/ipcam` и `/srv/ftps`, хук и LE-сертификат `cam.ruzzy.dev`. Видео так и не начали складываться (в `/srv/ipcam` на момент удаления 0 файлов). Хвосты вне сервера: DNS-запись в Namecheap и настройка аплоада на самой камере.
|
- **ipcam / FTPS-приёмник** (был `~/ipcam`, `fauria/vsftpd`, порты `2221:21` + `30000-30010`, домен `cam.ruzzy.dev`, данные `/srv/ipcam/videos`, сертификат `/srv/ftps/certs` + deploy-хук certbot) — **удалён 2026-09-08**: контейнер, образ, том `ipcam_ipcam_logs`, сеть, каталоги `/srv/ipcam` и `/srv/ftps`, хук и LE-сертификат `cam.ruzzy.dev`. Видео так и не начали складываться (в `/srv/ipcam` на момент удаления 0 файлов). Хвосты вне сервера: DNS-запись в Namecheap и настройка аплоада на самой камере.
|
||||||
@@ -214,7 +242,7 @@ backend=systemd banaction=ufw
|
|||||||
Полная инвентаризация на 2026-09-08 — чтобы «кто это сделал в 3 утра» не приходилось выяснять заново.
|
Полная инвентаризация на 2026-09-08 — чтобы «кто это сделал в 3 утра» не приходилось выяснять заново.
|
||||||
|
|
||||||
**Наше:**
|
**Наше:**
|
||||||
- `crontab -u rus`: `*/5 * * * *` → `~/openclaw/sync.sh` — синк MyVault в Gitea (см. claude-channels).
|
- `crontab -u rus` — с 2026-10-18 одно задание: `0 * * * * docker exec claude-channels node /root/publisher/publish.mjs` (разбор книги в DulyNotes; час 9/21 по Никосии проверяет сам скрипт, поэтому смена DST и TZ хоста не сдвигают слоты). Синк MyVault снят 2026-10-11 (см. claude-channels).
|
||||||
- `/etc/cron.d/staticroute` — `@reboot` правка маршрута, из образа Contabo (см. раздел «Сеть»).
|
- `/etc/cron.d/staticroute` — `@reboot` правка маршрута, из образа Contabo (см. раздел «Сеть»).
|
||||||
- `certbot.timer` + `/etc/cron.d/certbot` — продление сертификатов (крон-вариант выходит no-op на systemd).
|
- `certbot.timer` + `/etc/cron.d/certbot` — продление сертификатов (крон-вариант выходит no-op на systemd).
|
||||||
- `sysstat-collect.timer` (`*:00/10`) и `sysstat-summary.timer` (00:07) — сбор и суточный отчёт sar.
|
- `sysstat-collect.timer` (`*:00/10`) и `sysstat-summary.timer` (00:07) — сбор и суточный отчёт sar.
|
||||||
@@ -253,7 +281,7 @@ backend=systemd banaction=ufw
|
|||||||
- `~tgclaude/.openclaw-auth-profile-secrets` — права **777** на каталоге для секретов (сейчас пуст), стоит `chmod 700`.
|
- `~tgclaude/.openclaw-auth-profile-secrets` — права **777** на каталоге для секретов (сейчас пуст), стоит `chmod 700`.
|
||||||
- Не решено, нужны ли на fr1: `~rus/backups` (5.4 ГБ архивов с выведенного de1) и спящий openclaw (947 МБ дерева + 2.0 ГБ состояния + образ-снимок 1.55 ГБ).
|
- Не решено, нужны ли на fr1: `~rus/backups` (5.4 ГБ архивов с выведенного de1) и спящий openclaw (947 МБ дерева + 2.0 ГБ состояния + образ-снимок 1.55 ГБ).
|
||||||
- Страховочные файлы, оставленные после работ: `~rus/imgbot-repo-pre-merge-20260908-133444.tar.gz` (369 КБ), `~rus/imgbot-legacy-archive-rows-20260908.json` (808 КБ), `~rus/gitea/docker-compose.yml.bak-20260908`.
|
- Страховочные файлы, оставленные после работ: `~rus/imgbot-repo-pre-merge-20260908-133444.tar.gz` (369 КБ), `~rus/imgbot-legacy-archive-rows-20260908.json` (808 КБ), `~rus/gitea/docker-compose.yml.bak-20260908`.
|
||||||
- ⏳ Дроп-ин `/etc/ssh/sshd_config.d/50-cloud-init.conf` не читается из-под `rus` — под root проверить `sshd -T | grep -iE "permitrootlogin|passwordauth"`, чтобы знать эффективные значения, а не собранные из файлов вручную.
|
- ⏳ DNS-запись `astra.ruzzy.dev` (остаток отменённой интеграции с Марусей) — удалить у регистратора. Всё остальное на fr1 демонтировано 2026-10-11.
|
||||||
- ⚠️ Описания **других** серверов не сверялись: `servers/de1.md` и `servers/ru1.md` датируются временем до вывода de1, так что могут врать.
|
- ⚠️ Описания **других** серверов не сверялись: `servers/de1.md` и `servers/ru1.md` датируются временем до вывода de1, так что могут врать.
|
||||||
|
|
||||||
## Как это описание поддерживается
|
## Как это описание поддерживается
|
||||||
@@ -289,3 +317,5 @@ backend=systemd banaction=ufw
|
|||||||
|
|
||||||
## Установленные пакеты (сверх базы)
|
## Установленные пакеты (сверх базы)
|
||||||
htop, mtr-tiny, tmux, vim, curl, wget, git, unzip, net-tools, dnsutils, ncdu, rsync, fail2ban, unattended-upgrades, sysstat.
|
htop, mtr-tiny, tmux, vim, curl, wget, git, unzip, net-tools, dnsutils, ncdu, rsync, fail2ban, unattended-upgrades, sysstat.
|
||||||
|
|
||||||
|
- Исправление ложного PushMetricsStale подготовлено в infra 2026-10-11: правило `stacks/monitoring/prometheus/rules/alerts.yml` ограничено job=smart (было smart|backup_.* с порогом 3 ч для всех). backup_servers успешно завершился на rpi в 15:14, итог отправляется раз в сутки после прогона, поэтому алерт в 18:24 был ложным. Свежесть backup_* остаётся под BackupStale/BackupNeverReported с отдельными интервалами. ⚠️ Изменение пока только в репозитории: агенту fr1 требуется проверить правила promtool, установить alerts.yml и reload Prometheus; с rpi административного доступа к fr1 нет. Не перепушивать старые результаты ради исчезновения алерта.
|
||||||
|
|||||||
+151
-2
File diff suppressed because one or more lines are too long
@@ -0,0 +1,5 @@
|
|||||||
|
# books.ruzzy.dev
|
||||||
|
|
||||||
|
Код и стек переехали в отдельный репо **`ruzzy/books`** (gitea, приватный; локально — `~/pets/books`) 2026-10-13.
|
||||||
|
Здесь не дублируем: два источника кода уже однажды разъехались (см. astra-bot).
|
||||||
|
Состояние на сервере — `servers/fr1.md`, раздел «books.ruzzy.dev».
|
||||||
@@ -38,10 +38,12 @@ groups:
|
|||||||
|
|
||||||
# Метрики от rpi приходят через pushgateway (SSH-пуш, см. servers/rpi.md).
|
# Метрики от rpi приходят через pushgateway (SSH-пуш, см. servers/rpi.md).
|
||||||
# push_time_seconds — когда группа последний раз обновлялась.
|
# push_time_seconds — когда группа последний раз обновлялась.
|
||||||
|
# SMART отправляется каждые 15 минут. Итог backup_* — только после прогона;
|
||||||
|
# свежесть бэкапов проверяется BackupStale/BackupNeverReported по расписанию.
|
||||||
- name: rpi
|
- name: rpi
|
||||||
rules:
|
rules:
|
||||||
- alert: PushMetricsStale
|
- alert: PushMetricsStale
|
||||||
expr: time() - push_time_seconds{job=~"smart|backup_.*"} > 3 * 3600
|
expr: time() - push_time_seconds{job="smart"} > 3 * 3600
|
||||||
for: 10m
|
for: 10m
|
||||||
labels: {severity: warning}
|
labels: {severity: warning}
|
||||||
annotations:
|
annotations:
|
||||||
@@ -97,6 +99,7 @@ groups:
|
|||||||
or (time() - backup_last_success_timestamp{job="backup_onedrive_verify"} > 8 * 86400)
|
or (time() - backup_last_success_timestamp{job="backup_onedrive_verify"} > 8 * 86400)
|
||||||
or (time() - backup_last_success_timestamp{job="backup_servers"} > 26 * 3600)
|
or (time() - backup_last_success_timestamp{job="backup_servers"} > 26 * 3600)
|
||||||
or (time() - backup_last_success_timestamp{job="backup_router"} > 30 * 3600)
|
or (time() - backup_last_success_timestamp{job="backup_router"} > 30 * 3600)
|
||||||
|
or (time() - backup_last_success_timestamp{job="backup_vault"} > 3 * 3600)
|
||||||
or (time() - backup_last_success_timestamp{job="backup_router_sample"} > 35 * 86400)
|
or (time() - backup_last_success_timestamp{job="backup_router_sample"} > 35 * 86400)
|
||||||
for: 10m
|
for: 10m
|
||||||
labels: {severity: critical}
|
labels: {severity: critical}
|
||||||
@@ -189,3 +192,9 @@ groups:
|
|||||||
labels: {severity: warning}
|
labels: {severity: warning}
|
||||||
annotations:
|
annotations:
|
||||||
summary: "Дом: необычно большая ОТДАЧА в интернет — {{ $value | humanize1024 }}Б/с 30+ мин (облачная синхронизация, торрент или что-то подозрительное). Кто: `router traffic --live`."
|
summary: "Дом: необычно большая ОТДАЧА в интернет — {{ $value | humanize1024 }}Б/с 30+ мин (облачная синхронизация, торрент или что-то подозрительное). Кто: `router traffic --live`."
|
||||||
|
- alert: BackupNeverReported
|
||||||
|
expr: absent(backup_last_success_timestamp{instance="rpi",job="backup_vault"})
|
||||||
|
for: 3h
|
||||||
|
labels: {severity: warning, instance: rpi, job: backup_vault}
|
||||||
|
annotations:
|
||||||
|
summary: "rpi: архив Obsidian-vault в gitea не отчитывался 3+ ч — проверь vault-archive / obsidian-sync"
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ LOG="$ROOT/logs/sync-$TS.log"
|
|||||||
rclone sync onedrive: "$ROOT/current" \
|
rclone sync onedrive: "$ROOT/current" \
|
||||||
--backup-dir "$ROOT/archive/$TS" \
|
--backup-dir "$ROOT/archive/$TS" \
|
||||||
--exclude "/Personal Vault/**" \
|
--exclude "/Personal Vault/**" \
|
||||||
|
--metadata-exclude "malware-detected=true" --onedrive-av-override=false \
|
||||||
--transfers 4 --checkers 8 \
|
--transfers 4 --checkers 8 \
|
||||||
--retries 5 --low-level-retries 20 \
|
--retries 5 --low-level-retries 20 \
|
||||||
--log-file "$LOG" --log-level INFO \
|
--log-file "$LOG" --log-level INFO \
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ mountpoint -q /mnt/backup || { echo "/mnt/backup не смонтирован —
|
|||||||
rc=0
|
rc=0
|
||||||
|
|
||||||
rclone check onedrive: "$ROOT/current" --exclude "/Personal Vault/**" \
|
rclone check onedrive: "$ROOT/current" --exclude "/Personal Vault/**" \
|
||||||
|
--metadata-exclude "malware-detected=true" --onedrive-av-override=false \
|
||||||
--checkers 8 --combined "$ROOT/logs/verify-$TS.current.txt" \
|
--checkers 8 --combined "$ROOT/logs/verify-$TS.current.txt" \
|
||||||
--log-file "$LOG" --log-level NOTICE || rc=1
|
--log-file "$LOG" --log-level NOTICE || rc=1
|
||||||
|
|
||||||
|
|||||||
Executable
+21
@@ -0,0 +1,21 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Ежечасный (коммит только при изменениях) ОДНОСТОРОННИЙ архив Obsidian-vault в gitea (ruzzy/MyVault).
|
||||||
|
# Vault синхронизирует Obsidian Sync (obsidian-sync.service, ~astra/vault); git — только история:
|
||||||
|
# commit + push, никогда не pull — в vault отсюда ничего не попадает.
|
||||||
|
# Git-каталог отдельно (~astra/vault.git, core.worktree=~astra/vault), чтобы в vault не было .git.
|
||||||
|
# Скрытые файлы (.obsidian, данные плагинов, .gitignore) Sync на rpi не приносит — их не трогаем,
|
||||||
|
# иначе git записал бы их удаление.
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$HOME"
|
||||||
|
G=(git --git-dir="$HOME/vault.git" --work-tree="$HOME/vault")
|
||||||
|
systemctl is-active -q obsidian-sync || { echo "obsidian-sync не работает — архив устарел бы, пропуск"; exit 1; }
|
||||||
|
"${G[@]}" add -A -- . ":(exclude).*" ":(exclude)**/.*"
|
||||||
|
if "${G[@]}" diff --cached --quiet; then
|
||||||
|
echo "изменений нет"
|
||||||
|
else
|
||||||
|
stat=$("${G[@]}" diff --cached --shortstat)
|
||||||
|
"${G[@]}" commit -q -m "Архив vault $(date "+%F %H:%M")" -m "$stat"
|
||||||
|
echo "коммит: $stat"
|
||||||
|
fi
|
||||||
|
"${G[@]}" push -q origin HEAD:main
|
||||||
|
echo "OK: $("${G[@]}" log -1 --format='%h %s')"
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Obsidian Sync (headless) for vault /home/astra/vault
|
||||||
|
Wants=network-online.target
|
||||||
|
After=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
User=astra
|
||||||
|
Environment=HOME=/home/astra
|
||||||
|
WorkingDirectory=/home/astra/vault
|
||||||
|
ExecStart=/usr/bin/ob sync --path /home/astra/vault --continuous
|
||||||
|
Restart=always
|
||||||
|
RestartSec=30
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Hourly one-way archive of Obsidian vault to gitea (ruzzy/MyVault)
|
||||||
|
Wants=network-online.target
|
||||||
|
After=network-online.target obsidian-sync.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
User=astra
|
||||||
|
Environment=HOME=/home/astra
|
||||||
|
ExecStart=/usr/local/bin/vault-archive
|
||||||
|
ExecStopPost=+/usr/local/bin/backup-metrics backup_vault
|
||||||
|
TimeoutStartSec=15min
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Hourly Obsidian vault archive (commit only if changed)
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnCalendar=hourly
|
||||||
|
RandomizedDelaySec=5m
|
||||||
|
Persistent=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# Читалка books → Obsidian (rpi)
|
||||||
|
|
||||||
|
С 2026-10-18: заметки и рекапы из читалки books.ruzzy.dev (fr1) раз в 10 минут попадают в vault
|
||||||
|
владельца, папка **`Книги/`** — файл на книгу, главы разделами. Дальше их разносит Obsidian Sync (Mac, iPhone, Boox).
|
||||||
|
|
||||||
|
- **Доставка — pull с малины по HTTPS** (fr1 до малины за NAT не достучится): `GET https://books.ruzzy.dev/api/export?v=<версия>`
|
||||||
|
с Bearer-токеном **`export:obsidian-rpi`**. Токены с префиксом `export:` читалка пускает **только** на `/api/export`
|
||||||
|
(MCP и остальной API — 401). Ответ без изменений — `{"changed": false}`, данные не передаются.
|
||||||
|
- Токен — `/etc/books-sync/token` (root:astra 640), в читалке — только хэш (`api_token`). Отозвать:
|
||||||
|
`docker compose exec books python3 books_mcp.py revoke export:obsidian-rpi` на fr1; выдать новый — `add-token` (stdin).
|
||||||
|
- `/usr/local/bin/books-sync` (stdlib), `books-sync.timer` (каждые 10 мин после окончания прогона, юзер astra),
|
||||||
|
состояние `/var/lib/books-sync/state.json` (версия, book_id → файл).
|
||||||
|
- **Односторонняя:** всё выше `## Мои мысли` перезаписывается из читалки; сам раздел и всё ниже — не трогается
|
||||||
|
(свои мысли писать туда). Правки заметок — в читалке. Книгу переименовали — файл переименовывается вместе с «Моими мыслями».
|
||||||
|
- Диалоги с ИИ целиком не выгружаются (владелец 2026-10-18): нужное — кнопкой «В заметки» в окне объяснения, приходит обычной заметкой.
|
||||||
|
- Обвязка книги (обложка, копирайт, предисловия, благодарности, оглавление, указатель, «об авторе», колофон…) в файлы не попадает (владелец 2026-10-18), приложения — попадают.
|
||||||
|
- Файлы — только для книг, где есть заметки/рекапы. Длинные цитаты обрезаются до 1500 знаков.
|
||||||
|
Удалённая из читалки книга — файл остаётся.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo install -m 755 books-sync /usr/local/bin/ && sudo install -d -o astra -g astra -m 750 /var/lib/books-sync
|
||||||
|
sudo install -m 644 systemd/books-sync.* /etc/systemd/system/ && sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable --now books-sync.timer
|
||||||
|
sudo -u astra books-sync --force # переписать файлы принудительно
|
||||||
|
```
|
||||||
Executable
+162
@@ -0,0 +1,162 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""books-sync — заметки и рекапы читалки books.ruzzy.dev → Obsidian-vault владельца (папка «Книги/»).
|
||||||
|
|
||||||
|
Раз в 10 мин (books-sync.timer, юзер astra): GET /api/export с токеном «export:obsidian-rpi» (/etc/books-sync/token);
|
||||||
|
если версия не изменилась — ничего не делаем. Файл на книгу: «Книги/<название>.md», главы — разделами (рекап, заметки).
|
||||||
|
Диалоги с ИИ целиком не выгружаются (владелец 2026-10-18): нужное сохраняется кнопкой «В заметки» в окне объяснения —
|
||||||
|
ИИ сжимает разговор в обычную заметку, она и приходит сюда. Всё выше заголовка «## Мои мысли» перезаписывается из читалки, сам раздел и всё ниже — не трогаем.
|
||||||
|
Синхронизация односторонняя: правки заметок — в читалке. Только stdlib.
|
||||||
|
|
||||||
|
books-sync синхронизировать (таймер)
|
||||||
|
books-sync --force переписать файлы, даже если версия та же
|
||||||
|
"""
|
||||||
|
import datetime as dt
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
URL = os.environ.get("BOOKS_URL", "https://books.ruzzy.dev")
|
||||||
|
TOKEN = Path(os.environ.get("BOOKS_SYNC_TOKEN", "/etc/books-sync/token"))
|
||||||
|
STATE = Path(os.environ.get("BOOKS_SYNC_STATE", "/var/lib/books-sync/state.json"))
|
||||||
|
VAULT = Path(os.environ.get("ASTRA_VAULT", "/home/astra/vault"))
|
||||||
|
FOLDER = "Книги"
|
||||||
|
MINE = "## Мои мысли"
|
||||||
|
QUOTE_MAX = 1500
|
||||||
|
TZ = ZoneInfo("Asia/Nicosia")
|
||||||
|
KIND = {"note": "📝 Заметка", "mine": "✎ Стикер", "later": "⏳ Разобрать позже"}
|
||||||
|
# обвязка книги — в Obsidian не нужна (владелец 2026-10-18): обложка, выходные данные, предисловия, указатель…
|
||||||
|
FRONT_BACK = re.compile(r"(?i)^\s*(cover|copyright|table of contents|contents|preface|foreword|acknowledg\w*|index|"
|
||||||
|
r"about the authors?|about this book|colophon|dedication|начало книги|обложка|титул|предисловие|"
|
||||||
|
r"благодарност\w*|об этой книге|об автор\w*|иллюстрация на обложке|от издательства|оглавление|"
|
||||||
|
r"содержание|предметный указатель|указатель|выходные данные|посвящение)\b")
|
||||||
|
|
||||||
|
|
||||||
|
def fetch(version):
|
||||||
|
req = urllib.request.Request(f"{URL}/api/export?v={version}",
|
||||||
|
headers={"Authorization": "Bearer " + TOKEN.read_text().strip()})
|
||||||
|
with urllib.request.urlopen(req, timeout=60) as r:
|
||||||
|
return json.load(r)
|
||||||
|
|
||||||
|
|
||||||
|
def safe(name, n=90):
|
||||||
|
name = re.split(r"\s+--\s+", name or "")[0] # «Название -- Автор -- 2024 -- …» из имени файла
|
||||||
|
name = re.sub(r'[\\/:*?"<>|#^\[\]\x00-\x1f]', " ", name).strip(" .")
|
||||||
|
return re.sub(r"\s+", " ", name)[:n].rstrip(" .") or "Без названия"
|
||||||
|
|
||||||
|
|
||||||
|
def quote(text):
|
||||||
|
text = (text or "").strip()
|
||||||
|
if len(text) > QUOTE_MAX:
|
||||||
|
text = text[:QUOTE_MAX].rstrip() + " …"
|
||||||
|
return "\n".join("> " + l if l.strip() else ">" for l in text.splitlines())
|
||||||
|
|
||||||
|
|
||||||
|
def demote(md):
|
||||||
|
"""Заголовки внутри рекапа/ответа ИИ — ниже уровня главы, чтобы не ломать структуру файла."""
|
||||||
|
return re.sub(r"(?m)^(#{1,4}) ", lambda m: "#" * min(6, len(m.group(1)) + 3) + " ", (md or "").strip())
|
||||||
|
|
||||||
|
|
||||||
|
def day(ts):
|
||||||
|
try:
|
||||||
|
return dt.datetime.fromisoformat(ts).strftime("%d.%m.%Y")
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def render(site, b, chapters, notes, recaps, dialogs):
|
||||||
|
url = f"{site}/book/{b['id']}"
|
||||||
|
title = re.split(r"\s+--\s+", b["title"])[0]
|
||||||
|
out = ["---", f"книга: {json.dumps(title, ensure_ascii=False)}"]
|
||||||
|
if b.get("author"):
|
||||||
|
out.append(f"автор: {json.dumps(b['author'], ensure_ascii=False)}")
|
||||||
|
out += [f"читалка: {url}", "источник: books-sync — всё выше «Мои мысли» перезаписывается из читалки",
|
||||||
|
"tags: [книга]", "---", "", f"# {title}", ""]
|
||||||
|
out.append(" · ".join(x for x in (b.get("author") or "", f"[Открыть в читалке]({url})") if x))
|
||||||
|
idxs = sorted({x["chapter_idx"] for x in notes + recaps + dialogs})
|
||||||
|
for idx in idxs:
|
||||||
|
ch = chapters.get(idx, {"title": f"Глава {idx}"})
|
||||||
|
out += ["", f"## {ch['title']}", f"[в читалке]({url}/ch/{idx})"]
|
||||||
|
r = [x for x in recaps if x["chapter_idx"] == idx]
|
||||||
|
if r:
|
||||||
|
out += ["", "### Рекап", "", demote(r[0]["md"])]
|
||||||
|
ns = [x for x in notes if x["chapter_idx"] == idx]
|
||||||
|
if ns:
|
||||||
|
out += ["", "### Заметки"]
|
||||||
|
for n in ns:
|
||||||
|
meta = " · ".join(x for x in (KIND.get(n["kind"], n["kind"]), f"с. {n['page']}" if n.get("page") else "",
|
||||||
|
day(n.get("updated") or n.get("created")),
|
||||||
|
"✓ разобрано" if n.get("status") == "done" else "") if x)
|
||||||
|
out += ["", f"**{meta}**"]
|
||||||
|
if n["kind"] != "note" and n.get("quote"): # у «заметки» цитата уже в тексте (как в читалке)
|
||||||
|
out.append(quote(n["quote"]))
|
||||||
|
if (n.get("body") or "").strip():
|
||||||
|
out += ["", n["body"].strip()]
|
||||||
|
ds = [x for x in dialogs if x["chapter_idx"] == idx]
|
||||||
|
if ds:
|
||||||
|
out += ["", "### Диалоги с ИИ"]
|
||||||
|
for d in ds:
|
||||||
|
head = re.sub(r"\s+", " ", d.get("quote") or "").strip()
|
||||||
|
head = head[:70].rstrip() + ("…" if len(head) > 70 else "")
|
||||||
|
out += ["", f"#### «{head}» · {day(d.get('created'))}", quote(d.get("quote"))]
|
||||||
|
for m in d["msgs"]:
|
||||||
|
who = "**Я:**" if m["role"] == "user" else "**ИИ:**"
|
||||||
|
out += ["", who, demote(m["text"])]
|
||||||
|
return "\n".join(out).rstrip() + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
def write_book(path, generated):
|
||||||
|
"""Сгенерированная часть + раздел «Мои мысли» из прежнего файла (или пустой)."""
|
||||||
|
mine = f"\n{MINE}\n\n"
|
||||||
|
if path.exists():
|
||||||
|
old = path.read_text(encoding="utf-8")
|
||||||
|
i = old.find("\n" + MINE)
|
||||||
|
if i >= 0:
|
||||||
|
mine = old[i:]
|
||||||
|
text = generated + mine
|
||||||
|
if path.exists() and path.read_text(encoding="utf-8") == text:
|
||||||
|
return False
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
tmp = path.with_name("." + path.name + ".tmp")
|
||||||
|
tmp.write_text(text, encoding="utf-8")
|
||||||
|
os.replace(tmp, path)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
force = "--force" in sys.argv
|
||||||
|
state = json.loads(STATE.read_text()) if STATE.exists() else {}
|
||||||
|
data = fetch("" if force else state.get("version", ""))
|
||||||
|
if not data.get("changed"):
|
||||||
|
return
|
||||||
|
chapters = {}
|
||||||
|
for ch in data["chapters"]:
|
||||||
|
chapters.setdefault(ch["book_id"], {})[ch["idx"]] = ch
|
||||||
|
files = state.get("files", {})
|
||||||
|
changed = []
|
||||||
|
for b in data["books"]:
|
||||||
|
chs = chapters.get(b["id"], {})
|
||||||
|
sel = lambda xs: [x for x in xs if x["book_id"] == b["id"]
|
||||||
|
and not FRONT_BACK.match(chs.get(x["chapter_idx"], {}).get("title", ""))]
|
||||||
|
notes, recaps, dialogs = sel(data["notes"]), sel(data["recaps"]), [] # диалоги — только через «В заметки»
|
||||||
|
if not (notes or recaps or dialogs) and str(b["id"]) not in files:
|
||||||
|
continue # уже был файл, а заметок не осталось — перепишем шапкой, «Мои мысли» сохранятся
|
||||||
|
rel = f"{FOLDER}/{safe(b['title'])}.md"
|
||||||
|
old = files.get(str(b["id"]))
|
||||||
|
if old and old != rel and (VAULT / old).exists() and not (VAULT / rel).exists():
|
||||||
|
os.replace(VAULT / old, VAULT / rel) # книгу переименовали — файл вместе с «Моими мыслями»
|
||||||
|
if write_book(VAULT / rel, render(data["site"], b, chapters.get(b["id"], {}), notes, recaps, dialogs)):
|
||||||
|
changed.append(rel)
|
||||||
|
files[str(b["id"])] = rel
|
||||||
|
STATE.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
STATE.write_text(json.dumps({"version": data["version"], "files": files,
|
||||||
|
"synced": dt.datetime.now(TZ).isoformat(timespec="seconds")}, ensure_ascii=False))
|
||||||
|
for rel in changed:
|
||||||
|
print("обновлено:", rel)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Читалка books → Obsidian: заметки, рекапы, диалоги в «Книги/»
|
||||||
|
Wants=network-online.target
|
||||||
|
After=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
User=astra
|
||||||
|
Group=astra
|
||||||
|
ExecStart=/usr/local/bin/books-sync
|
||||||
|
TimeoutStartSec=5min
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Читалка books → Obsidian каждые 10 минут
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnBootSec=5min
|
||||||
|
OnUnitInactiveSec=10min
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Notify owner when Burt Wonderstone is ready in Jellyfin
|
||||||
|
After=network-online.target docker.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
ExecStart=/usr/bin/python3 /usr/local/lib/media-ready/notify-movie-ready.py
|
||||||
|
StateDirectory=media-ready
|
||||||
|
UMask=0077
|
||||||
|
NoNewPrivileges=true
|
||||||
|
PrivateTmp=true
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=true
|
||||||
|
TimeoutStartSec=120
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Check requested movie readiness every two minutes
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnBootSec=2min
|
||||||
|
OnUnitInactiveSec=2min
|
||||||
|
Unit=notify-movie-ready.service
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
ничего не ломает, уже сделанное пропускает.
|
ничего не ломает, уже сделанное пропускает.
|
||||||
|
|
||||||
Запуск на малине: python3 /opt/media/provision.py [шаг ...]
|
Запуск на малине: python3 /opt/media/provision.py [шаг ...]
|
||||||
Шаги: qbit jellyfin arr prowlarr bazarr seerr connect homepage (без аргументов — все).
|
Шаги: qbit jellyfin arr langs prowlarr bazarr seerr connect homepage (без аргументов — все).
|
||||||
Пароль админа берётся из /opt/media/.admin-password, API-ключи — из config.xml.
|
Пароль админа берётся из /opt/media/.admin-password, API-ключи — из config.xml.
|
||||||
"""
|
"""
|
||||||
from http.cookiejar import CookieJar
|
from http.cookiejar import CookieJar
|
||||||
@@ -187,6 +187,48 @@ def arr():
|
|||||||
log(name, "ok")
|
log(name, "ok")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- языки в *arr
|
||||||
|
def langs():
|
||||||
|
"""Приоритет дорожек: русская+оригинал (150) > русская (100) > оригинал (50). Ничего не отклоняется,
|
||||||
|
а cutoffFormatScore=150 + upgradeAllowed — *arr сами перекачают, когда появится раздача с русской дорожкой
|
||||||
|
(у свежих фильмов дубляж выходит через недели после оригинала)."""
|
||||||
|
for name, a in ARR.items():
|
||||||
|
h = {"X-Api-Key": apikey(name)}
|
||||||
|
u = f"{a['url']}/api/{a['api']}"
|
||||||
|
st, ll = http("GET", u + "/language", headers=h)
|
||||||
|
lid = {l["name"]: l["id"] for l in ll}
|
||||||
|
spec = lambda n, impl, v: {"name": n, "implementation": impl, "negate": False, "required": False,
|
||||||
|
"fields": [{"name": "value", "value": v}]}
|
||||||
|
orig = ("Оригинальная дорожка", "Original") if name == "radarr" else ("Английская дорожка", "English")
|
||||||
|
defs = {
|
||||||
|
# RuTracker с опцией «добавить RUS»: язык может не распознаться, а тег в названии есть
|
||||||
|
"Русская дорожка": [spec("Russian", "LanguageSpecification", lid["Russian"]),
|
||||||
|
spec("RUS в названии", "ReleaseTitleSpecification", r"\b(RUS|Rus|MVO|DVO|Dub|Дубляж)\b")],
|
||||||
|
orig[0]: [spec(orig[1], "LanguageSpecification", lid[orig[1]])],
|
||||||
|
}
|
||||||
|
scores = {"Русская дорожка": 100, orig[0]: 50}
|
||||||
|
st, cfs = http("GET", u + "/customformat", headers=h)
|
||||||
|
ids = {c["name"]: c["id"] for c in cfs}
|
||||||
|
for cname, specs in defs.items():
|
||||||
|
if cname not in ids:
|
||||||
|
st, r = http("POST", u + "/customformat",
|
||||||
|
{"name": cname, "includeCustomFormatWhenRenaming": False, "specifications": specs}, h)
|
||||||
|
ids[cname] = r["id"]
|
||||||
|
st, profs = http("GET", u + "/qualityprofile", headers=h)
|
||||||
|
for p in profs:
|
||||||
|
items = {fi["format"]: fi for fi in p["formatItems"]}
|
||||||
|
for cname, sc in scores.items():
|
||||||
|
if ids[cname] in items:
|
||||||
|
items[ids[cname]]["score"] = sc
|
||||||
|
else:
|
||||||
|
p["formatItems"].append({"format": ids[cname], "name": cname, "score": sc})
|
||||||
|
p.update({"minFormatScore": 0, "cutoffFormatScore": 150, "upgradeAllowed": True})
|
||||||
|
if name == "radarr": # было Original — отклоняло раздачи только с дубляжом
|
||||||
|
p["language"] = {"id": lid["Any"], "name": "Any"}
|
||||||
|
http("PUT", u + "/qualityprofile/%d" % p["id"], p, h)
|
||||||
|
log(name, "langs ok")
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------- Prowlarr
|
# ---------------------------------------------------------------- Prowlarr
|
||||||
def prowlarr():
|
def prowlarr():
|
||||||
k = apikey("prowlarr")
|
k = apikey("prowlarr")
|
||||||
@@ -385,7 +427,7 @@ def jellyseerr():
|
|||||||
log("seerr ok")
|
log("seerr ok")
|
||||||
|
|
||||||
|
|
||||||
STEPS = {"qbit": qbit, "jellyfin": jellyfin, "arr": arr, "prowlarr": prowlarr, "bazarr": bazarr, "connect": connect, "homepage": homepage, "seerr": jellyseerr}
|
STEPS = {"qbit": qbit, "jellyfin": jellyfin, "arr": arr, "langs": langs, "prowlarr": prowlarr, "bazarr": bazarr, "connect": connect, "homepage": homepage, "seerr": jellyseerr}
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
for s in sys.argv[1:] or list(STEPS):
|
for s in sys.argv[1:] or list(STEPS):
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Enable owner Telegram notifications for Chernobyl imports; run as root."""
|
||||||
|
import json
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
def env(path):
|
||||||
|
return dict(line.strip().split('=', 1) for line in open(path)
|
||||||
|
if '=' in line and not line.startswith('#'))
|
||||||
|
|
||||||
|
key = env('/etc/media.env')['SONARR_KEY']
|
||||||
|
|
||||||
|
def api(path, data=None, method=None):
|
||||||
|
req = urllib.request.Request('http://127.0.0.1:8989/api/v3/' + path,
|
||||||
|
data=None if data is None else json.dumps(data).encode(),
|
||||||
|
headers={'X-Api-Key': key, 'Content-Type': 'application/json'},
|
||||||
|
method=method or ('GET' if data is None else 'POST'))
|
||||||
|
with urllib.request.urlopen(req, timeout=30) as response:
|
||||||
|
return json.load(response)
|
||||||
|
|
||||||
|
series = next(s for s in api('series') if s['tvdbId'] == 360893)
|
||||||
|
tags = api('tag')
|
||||||
|
tag = next((t for t in tags if t['label'] == 'notify-owner-chernobyl'), None)
|
||||||
|
if tag is None:
|
||||||
|
tag = api('tag', {'label': 'notify-owner-chernobyl'})
|
||||||
|
if tag['id'] not in series['tags']:
|
||||||
|
series['tags'].append(tag['id'])
|
||||||
|
api('series/' + str(series['id']), series, 'PUT')
|
||||||
|
name = 'Telegram: Chernobyl owner'
|
||||||
|
existing = next((n for n in api('notification') if n['name'] == name), None)
|
||||||
|
notification = existing or next(n for n in api('notification/schema') if n['implementation'] == 'Telegram')
|
||||||
|
for field in notification['fields']:
|
||||||
|
values = {'botToken': env('/etc/astra-bot.env')['TG_TOKEN'], 'chatId': '607015',
|
||||||
|
'sendSilently': False, 'includeAppNameInTitle': True}
|
||||||
|
if field['name'] in values:
|
||||||
|
field['value'] = values[field['name']]
|
||||||
|
for event in list(notification):
|
||||||
|
if event.startswith('on') and isinstance(notification[event], bool):
|
||||||
|
notification[event] = False
|
||||||
|
notification.update(name=name, tags=[tag['id']], onDownload=True)
|
||||||
|
api('notification/' + str(existing['id']) if existing else 'notification', notification,
|
||||||
|
'PUT' if existing else 'POST')
|
||||||
|
check = next(n for n in api('notification') if n['name'] == name)
|
||||||
|
assert check['onDownload'] and check['tags'] == [tag['id']]
|
||||||
|
print('Configured: Telegram notifications for Chernobyl episode imports, owner chat 607015.')
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Send one owner notification after Radarr import and Jellyfin indexing."""
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
TMDB = 124459
|
||||||
|
STATE = Path('/var/lib/media-ready/burt-wonderstone.sent')
|
||||||
|
|
||||||
|
def env(path):
|
||||||
|
return dict(line.strip().split('=', 1) for line in open(path)
|
||||||
|
if '=' in line and not line.startswith('#'))
|
||||||
|
|
||||||
|
def api(base, path, headers=None, data=None):
|
||||||
|
request = urllib.request.Request(base + path, headers=headers or {},
|
||||||
|
data=None if data is None else json.dumps(data).encode())
|
||||||
|
with urllib.request.urlopen(request, timeout=30) as response:
|
||||||
|
body = response.read()
|
||||||
|
return json.loads(body) if body else None
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if STATE.exists():
|
||||||
|
return
|
||||||
|
keys = env('/etc/media.env')
|
||||||
|
movies = api('http://127.0.0.1:7878', '/api/v3/movie',
|
||||||
|
{'X-Api-Key': keys['RADARR_KEY']})
|
||||||
|
movie = next((m for m in movies if m['tmdbId'] == TMDB), None)
|
||||||
|
if not movie or not movie.get('hasFile'):
|
||||||
|
print('Waiting for Radarr import')
|
||||||
|
return
|
||||||
|
base = 'http://127.0.0.1:8096'
|
||||||
|
headers = {'Authorization': 'MediaBrowser Token="' + keys['JELLYFIN_KEY'] + '"'}
|
||||||
|
items = api(base, '/Items?Recursive=true&IncludeItemTypes=Movie&Fields=ProviderIds', headers)
|
||||||
|
item = next((i for i in items['Items']
|
||||||
|
if i.get('ProviderIds', {}).get('Tmdb') == str(TMDB)), None)
|
||||||
|
if not item:
|
||||||
|
api(base, '/Library/Refresh', headers, {})
|
||||||
|
print('Waiting for Jellyfin index; refresh requested')
|
||||||
|
return
|
||||||
|
server = api(base, '/System/Info/Public')['Id']
|
||||||
|
link = ('http://192.168.10.25:8096/web/#/details?id='
|
||||||
|
+ item['Id'] + '&serverId=' + server)
|
||||||
|
token = env('/etc/astra-bot.env')['TG_TOKEN']
|
||||||
|
result = api('https://api.telegram.org', '/bot' + token + '/sendMessage',
|
||||||
|
{'Content-Type': 'application/json'},
|
||||||
|
{'chat_id': 607015, 'text': '«Невероятный Бёрт Уандерстоун» скачался. '
|
||||||
|
'Русская озвучка, 1080p. Смотреть дома:\n' + link,
|
||||||
|
'disable_web_page_preview': True})
|
||||||
|
if not result.get('ok'):
|
||||||
|
raise RuntimeError('Telegram did not confirm delivery')
|
||||||
|
STATE.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
STATE.write_text(str(result['result']['message_id']) + '\n')
|
||||||
|
print('Ready notification delivered')
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
# urllib exceptions can contain the Telegram token in their URL.
|
||||||
|
try:
|
||||||
|
main()
|
||||||
|
except Exception as error:
|
||||||
|
print('Movie notification failed: ' + type(error).__name__)
|
||||||
|
raise SystemExit(1)
|
||||||
@@ -21,3 +21,10 @@ Word DOCX перед печатью нужно преобразовать в PDF
|
|||||||
|
|
||||||
Отправлять на бумагу только по прямой просьбе пользователя. Сообщение о принятии
|
Отправлять на бумагу только по прямой просьбе пользователя. Сообщение о принятии
|
||||||
задания означает приём в очередь; итог следует проверить через `lpstat`.
|
задания означает приём в очередь; итог следует проверить через `lpstat`.
|
||||||
|
|
||||||
|
Печать по просьбе ребёнка (уточнение владельца 2026-10-13): обычная — PDF в её чат, печать по её
|
||||||
|
«Печатай», этот же `home-print`. Владелец кнопкой в Telegram разрешает не печать, а работу с конкретным
|
||||||
|
материалом не по возрасту (`material-approval-request`, до подготовки текста). Код — `ruzzy/astra-bot`
|
||||||
|
(`material_approvals.py`, `codex/skills/home-print/`); `home-print-request` (PDF владельцу перед печатью,
|
||||||
|
`print_requests.py`) остался необязательным режимом. ⚠️ astra-bot `d916407` ещё не установлен: на хосте
|
||||||
|
действует прежнее «вся печать Поли — после кнопки владельца». Подробности — `servers/rpi.md`, «Домашняя печать».
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# Samba на rpi — просмотр бэкапа OneDrive с Mac (Finder → smb://rpi.local). Только чтение.
|
||||||
|
# Доступ: только домашняя сеть, только пользователь rus (пароль Samba — отдельный, smbpasswd -a rus).
|
||||||
|
[global]
|
||||||
|
workgroup = WORKGROUP
|
||||||
|
server string = rpi
|
||||||
|
server role = standalone server
|
||||||
|
interfaces = lo eth0
|
||||||
|
bind interfaces only = yes
|
||||||
|
hosts allow = 192.168.10.0/24 127.0.0.1
|
||||||
|
hosts deny = 0.0.0.0/0
|
||||||
|
server min protocol = SMB2_10
|
||||||
|
map to guest = never
|
||||||
|
restrict anonymous = 2
|
||||||
|
load printers = no
|
||||||
|
printing = bsd
|
||||||
|
printcap name = /dev/null
|
||||||
|
disable spoolss = yes
|
||||||
|
log file = /var/log/samba/log.%m
|
||||||
|
max log size = 1000
|
||||||
|
logging = file
|
||||||
|
# macOS: Finder, Quick Look, превью, корректные имена с «:» и др.
|
||||||
|
vfs objects = catia fruit streams_xattr
|
||||||
|
fruit:metadata = stream
|
||||||
|
fruit:model = MacSamba
|
||||||
|
fruit:posix_rename = yes
|
||||||
|
fruit:veto_appledouble = no
|
||||||
|
fruit:nfs_aces = no
|
||||||
|
fruit:wipe_intentionally_left_blank_rfork = yes
|
||||||
|
fruit:delete_empty_adfiles = yes
|
||||||
|
multicast dns register = yes
|
||||||
|
|
||||||
|
[onedrive]
|
||||||
|
comment = Зеркало OneDrive (бэкап rpi), только чтение
|
||||||
|
path = /mnt/backup/onedrive/current
|
||||||
|
read only = yes
|
||||||
|
valid users = rus
|
||||||
|
browseable = yes
|
||||||
|
|
||||||
|
[onedrive-archive]
|
||||||
|
comment = Всё, что удалили или перезаписали в OneDrive (вечный архив), только чтение
|
||||||
|
path = /mnt/backup/onedrive/archive
|
||||||
|
read only = yes
|
||||||
|
valid users = rus
|
||||||
|
browseable = yes
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# Выгрузка Telegram — второй аккаунт владельца (rpi)
|
||||||
|
|
||||||
|
Проект `tg_export` (Telethon, MTProto) перенесён с Mac 2026-10-17: на малине — **второй аккаунт владельца**,
|
||||||
|
регулярная докачка **только текста** (медиа — позже, если понадобится), поиск (RAG) и утренняя сводка по темам. Старая выгрузка первого аккаунта
|
||||||
|
(~14 ГБ) осталась на Mac в `~/pets/tg_export/out`, сюда не переносилась; анализ (`stats`, `trips`, `presence`…) — там же.
|
||||||
|
|
||||||
|
| Что | Где |
|
||||||
|
|---|---|
|
||||||
|
| Код | `/opt/tg-export/` (`export.py`, `fetch_public.py`, `channel_comments.py`), venv `/opt/tg-export/venv` |
|
||||||
|
| Обёртка | `/usr/local/bin/tg-export` — подставляет `--session` и `--out`, ключи из env |
|
||||||
|
| Ключи приложения (`TG_API_ID`/`TG_API_HASH`, my.telegram.org) | `/etc/tg-export/env` — root:tgexport 640, в git нет |
|
||||||
|
| Сессия (= **полный доступ к аккаунту**) | `/var/lib/tg-export/tg.session` — tgexport 600 |
|
||||||
|
| Выгрузка | `/mnt/backup/tg/` — `dialogs.json`, `chats/<id>_<название>/{messages.jsonl,messages.txt,meta.json,_state.json}`; tgexport:astra, каталоги 2750, файлы 640 |
|
||||||
|
| Чаты | только выбранные владельцем (`--only` в юните, 2026-10-18): @CupRusHelpers, Cyprus Car – Chat (`-1001315261949`), @cyprusdiy, @cylaw, @cymedic, @badcyprus. Личные переписки и боты (в т.ч. рабочие) — нет. История — **с 2024-10-18** (`--since`, последние 2 года на момент запуска; дата фиксирована, дальше копится всё новое). ⚠️ В группах со скрытой для новых участников историей видно только с даты вступления (CypRus Helpers — с 2026-06-20). ⚠️ Выгружаются только чаты, где аккаунт состоит: не вступивший `--only` молча пропускает |
|
||||||
|
| Таймер | `tg-export.timer` — **каждый час** ±5 мин (было 04:30 раз в сутки), `tg-export.service` (oneshot, `run --no-media --archived`, затем `tgrag.py index`) |
|
||||||
|
| Мониторинг | `ExecStopPost=+backup-metrics backup_tg_export` → алерты `BackupFailed` / `BackupStale` (>30 ч) |
|
||||||
|
|
||||||
|
Пользователь `tgexport` — системный, без шелла: Astra (группа astra) читает тексты, но **не видит сессию и ключи**.
|
||||||
|
|
||||||
|
## Установка и вход
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo sh install.sh # код, venv, пользователь, каталоги, юниты
|
||||||
|
sudo install -o root -g tgexport -m 640 env /etc/tg-export/env # ключи приложения (как .env.example)
|
||||||
|
ssh -t rpi sudo -u tgexport tg-export login # номер, код из Telegram, 2FA — делает владелец
|
||||||
|
sudo systemctl enable --now tg-export.timer
|
||||||
|
```
|
||||||
|
|
||||||
|
Вручную: `sudo -u tgexport tg-export list` (диалоги), `sudo -u tgexport tg-export run --no-media --only @чат`.
|
||||||
|
Повторный запуск догружает только новое (`_state.json` в каждом чате), `--full` — перезалить чат.
|
||||||
|
|
||||||
|
⚠️ Первый прогон долгий: Telegram троттлит массовое чтение (FloodWait, скрипт ждёт до 24 ч) — `BackupStale`
|
||||||
|
может сработать, пока он идёт; следующий запуск таймера не стартует поверх идущего.
|
||||||
|
⚠️ Медиа выключены (`--no-media`); включить — убрать флаг в юните (лимит по умолчанию 50 МБ на файл).
|
||||||
|
|
||||||
|
## Поиск (RAG) и утренняя сводка — `tg` (с 2026-10-18)
|
||||||
|
|
||||||
|
`/opt/tg-export/tgrag.py`, обёртка `/usr/local/bin/tg` (запускает astra):
|
||||||
|
|
||||||
|
- **Индекс** `/mnt/backup/tg/index.db` (SQLite, журнал DELETE — не WAL, чтобы astra читала без записи в `-shm`): кусок =
|
||||||
|
подряд идущие сообщения одного чата с паузами < 20 мин, до ~800 знаков, строки «Автор: текст»; FTS5 + эмбеддинги.
|
||||||
|
Пишет `tgexport` (`ExecStartPost` после каждой докачки), инкрементально по id; последний кусок пересобирается,
|
||||||
|
если разговор продолжился.
|
||||||
|
- **Модель** — `minishlab/potion-multilingual-128M` (fastembed, статические эмбеддинги, `/opt/tg-export/models`).
|
||||||
|
Сравнение на 8 тыс. сообщений групп 2026-10-18: potion 57 с, `paraphrase-multilingual-MiniLM-L12-v2` 915 с при
|
||||||
|
сопоставимом качестве поиска (MiniLM к тому же видит только ~128 токенов куска). e5-large/jina-v3 на Pi 4 —
|
||||||
|
дни на историю. Сменить — `TG_EMB_MODEL` + переиндексация (удалить `index.db`).
|
||||||
|
- `tg search ЗАПРОС [--chat] [--since 90d] [-n]` — гибрид BM25 + косинус (RRF), куски со ссылками t.me
|
||||||
|
(`t.me/<username>/<id>` или `t.me/c/<id>/<msg>`). ⚠️ Одна формулировка часто промахивается
|
||||||
|
(«посоветуйте спортзал» не нашло «фитнес в отелях») — Astra по навыку `tg` переформулирует 2–4 раза.
|
||||||
|
- **Сводка**: темы `tg topic add "Тема" --query "ключевые слова"` → `/var/lib/tg-report/topics.json`;
|
||||||
|
`tg-report.timer` 08:00 **Asia/Nicosia** (не съедет при переходе Кипра на зимнее время) → `tg report`: по теме —
|
||||||
|
найденное за 24 ч (словами — всегда, по смыслу — близость ≥ 0.4), сжимает **Codex** (`codex exec`, подписка Astra —
|
||||||
|
не лимит Claude читалки) → очередь `/var/lib/astra-google/outbox/` → `gcal remind` шлёт владельцу.
|
||||||
|
⚠️ Очередь шлёт одним `sendMessage` без разбиения: сводка режется на части ≤ 3800 знаков (иначе файл > 4096
|
||||||
|
застрял бы и заблокировал очередь). Тем нет или индекса нет — ничего не шлёт. `tg report --dry` — посмотреть.
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# https://my.telegram.org → API development tools → создать приложение
|
||||||
|
TG_API_ID=1234567
|
||||||
|
TG_API_HASH=0123456789abcdef0123456789abcdef
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Выгрузка канала вместе с комментариями из привязанной группы обсуждений.
|
||||||
|
|
||||||
|
Обычный `export.py run` берёт только посты канала: комментарии физически
|
||||||
|
лежат в отдельном linked chat и через iter_dialogs не приходят. Здесь для
|
||||||
|
каждого поста дополнительно вытягивается его ветка (iter_messages(reply_to=...)),
|
||||||
|
плюс, при --with-linked, вся история группы обсуждений целиком — там бывают
|
||||||
|
сообщения, не привязанные ни к одному посту.
|
||||||
|
|
||||||
|
uv run channel_comments.py @cpython_notes
|
||||||
|
uv run channel_comments.py @cpython_notes --with-linked --session copy.session
|
||||||
|
|
||||||
|
Формат вывода совпадает с export.py: сырой to_dict() в *.jsonl, плоское
|
||||||
|
представление в *_pretty.jsonl, читаемая расшифровка в *.txt.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import datetime as dt
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from telethon import TelegramClient, errors, functions
|
||||||
|
|
||||||
|
import export as ex
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent
|
||||||
|
|
||||||
|
|
||||||
|
async def dump_stream(client, entity, it, jsonl, pretty, txt, cache, label, tag=None):
|
||||||
|
"""Прогоняет итератор сообщений в три файла. Возвращает счётчик."""
|
||||||
|
n = 0
|
||||||
|
async for msg in it:
|
||||||
|
if ex.STOP:
|
||||||
|
break
|
||||||
|
record = msg.to_dict()
|
||||||
|
if tag:
|
||||||
|
record.update(tag)
|
||||||
|
sender = await ex.sender_label(client, msg, cache)
|
||||||
|
jsonl.write(json.dumps(record, ensure_ascii=False, cls=ex.Encoder) + "\n")
|
||||||
|
if pretty:
|
||||||
|
flat = ex.simplify_message(msg, sender, record)
|
||||||
|
if tag:
|
||||||
|
flat.update(tag)
|
||||||
|
pretty.write(json.dumps(flat, ensure_ascii=False, cls=ex.Encoder) + "\n")
|
||||||
|
if txt:
|
||||||
|
txt.write(ex.render_line(msg, sender))
|
||||||
|
n += 1
|
||||||
|
if n % 500 == 0:
|
||||||
|
print(f" … {label}: {n}", flush=True)
|
||||||
|
return n
|
||||||
|
|
||||||
|
|
||||||
|
async def main_async(args) -> None:
|
||||||
|
api_id, api_hash = ex.require_credentials()
|
||||||
|
client = TelegramClient(args.session, api_id, api_hash)
|
||||||
|
client.flood_sleep_threshold = 24 * 60 * 60
|
||||||
|
await client.start()
|
||||||
|
try:
|
||||||
|
chan = await client.get_entity(args.channel)
|
||||||
|
full = await client(functions.channels.GetFullChannelRequest(channel=chan))
|
||||||
|
linked_id = getattr(full.full_chat, "linked_chat_id", None)
|
||||||
|
linked = None
|
||||||
|
if linked_id:
|
||||||
|
linked = next((c for c in full.chats if c.id == linked_id), None)
|
||||||
|
|
||||||
|
dialog_id = int(f"-100{chan.id}")
|
||||||
|
folder = Path(args.out) / "chats" / f"{dialog_id}_{ex.slugify(getattr(chan, 'title', args.channel))}"
|
||||||
|
folder.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
header = {
|
||||||
|
"id": dialog_id,
|
||||||
|
"name": getattr(chan, "title", None),
|
||||||
|
"kind": ex.dialog_kind(chan),
|
||||||
|
"username": getattr(chan, "username", None),
|
||||||
|
"participants": getattr(full.full_chat, "participants_count", None),
|
||||||
|
"linked_chat_id": linked_id,
|
||||||
|
"linked_chat_title": getattr(linked, "title", None) if linked else None,
|
||||||
|
"exported_at": dt.datetime.now(dt.timezone.utc).isoformat(),
|
||||||
|
}
|
||||||
|
(folder / "meta.json").write_text(
|
||||||
|
json.dumps({**header, "entity": chan.to_dict()},
|
||||||
|
ensure_ascii=False, indent=2, cls=ex.Encoder),
|
||||||
|
encoding="utf-8")
|
||||||
|
|
||||||
|
print(f"Канал: {header['name']} (@{header['username']}) · "
|
||||||
|
f"подписчиков {header['participants']}")
|
||||||
|
print(f"Группа обсуждений: {header['linked_chat_title']} (id={linked_id})\n"
|
||||||
|
if linked_id else "Группа обсуждений не привязана — комментариев нет\n")
|
||||||
|
|
||||||
|
cache: dict = {}
|
||||||
|
mode = "w" if args.full else "a"
|
||||||
|
|
||||||
|
# ---- посты канала ----
|
||||||
|
pj = (folder / "messages.jsonl").open(mode, encoding="utf-8")
|
||||||
|
pp = (folder / "_pretty.jsonl").open(mode, encoding="utf-8")
|
||||||
|
pt = (folder / "messages.txt").open(mode, encoding="utf-8")
|
||||||
|
posts = []
|
||||||
|
async for m in client.iter_messages(chan, reverse=True):
|
||||||
|
posts.append(m)
|
||||||
|
for m in posts:
|
||||||
|
record = m.to_dict()
|
||||||
|
sender = await ex.sender_label(client, m, cache)
|
||||||
|
pj.write(json.dumps(record, ensure_ascii=False, cls=ex.Encoder) + "\n")
|
||||||
|
pp.write(json.dumps(ex.simplify_message(m, sender, record),
|
||||||
|
ensure_ascii=False, cls=ex.Encoder) + "\n")
|
||||||
|
pt.write(ex.render_line(m, sender))
|
||||||
|
for f in (pj, pp, pt):
|
||||||
|
f.close()
|
||||||
|
print(f"постов: {len(posts)}")
|
||||||
|
|
||||||
|
if not linked_id:
|
||||||
|
return
|
||||||
|
|
||||||
|
# ---- комментарии: по ветке на каждый пост ----
|
||||||
|
cj = (folder / "comments.jsonl").open(mode, encoding="utf-8")
|
||||||
|
cp = (folder / "comments_pretty.jsonl").open(mode, encoding="utf-8")
|
||||||
|
thread = (folder / "threads.txt").open(mode, encoding="utf-8")
|
||||||
|
total_c = 0
|
||||||
|
with_c = 0
|
||||||
|
for i, post in enumerate(posts, 1):
|
||||||
|
if ex.STOP:
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
it = client.iter_messages(chan, reply_to=post.id, reverse=True)
|
||||||
|
n = 0
|
||||||
|
head_written = False
|
||||||
|
async for cm in it:
|
||||||
|
if not head_written:
|
||||||
|
head = (post.message or "").split("\n")[0][:90]
|
||||||
|
thread.write(f"\n{'='*78}\nПОСТ #{post.id} "
|
||||||
|
f"[{post.date:%Y-%m-%d %H:%M}] {head}\n{'-'*78}\n")
|
||||||
|
head_written = True
|
||||||
|
record = cm.to_dict()
|
||||||
|
record["_post_id"] = post.id
|
||||||
|
sender = await ex.sender_label(client, cm, cache)
|
||||||
|
cj.write(json.dumps(record, ensure_ascii=False, cls=ex.Encoder) + "\n")
|
||||||
|
flat = ex.simplify_message(cm, sender, record)
|
||||||
|
flat["_post_id"] = post.id
|
||||||
|
cp.write(json.dumps(flat, ensure_ascii=False, cls=ex.Encoder) + "\n")
|
||||||
|
thread.write(" " + ex.render_line(cm, sender).replace("\n", "\n ").rstrip() + "\n")
|
||||||
|
n += 1
|
||||||
|
except (errors.MsgIdInvalidError, errors.ChannelPrivateError) as exc:
|
||||||
|
# у поста отключены комментарии либо ветка недоступна
|
||||||
|
print(f" пост #{post.id}: {type(exc).__name__}", file=sys.stderr)
|
||||||
|
continue
|
||||||
|
except errors.FloodWaitError as exc:
|
||||||
|
print(f" FloodWait {exc.seconds}s на посте #{post.id} — жду",
|
||||||
|
file=sys.stderr)
|
||||||
|
await asyncio.sleep(exc.seconds + 1)
|
||||||
|
continue
|
||||||
|
total_c += n
|
||||||
|
if n:
|
||||||
|
with_c += 1
|
||||||
|
print(f" [{i}/{len(posts)}] пост #{post.id}: {n} комментариев", flush=True)
|
||||||
|
for f in (cj, cp, thread):
|
||||||
|
f.close()
|
||||||
|
print(f"\nкомментариев: {total_c} в {with_c} ветках "
|
||||||
|
f"(из {len(posts)} постов)")
|
||||||
|
|
||||||
|
# ---- вся группа обсуждений целиком ----
|
||||||
|
if args.with_linked:
|
||||||
|
lj = (folder / "linked_chat.jsonl").open(mode, encoding="utf-8")
|
||||||
|
lp = (folder / "linked_chat_pretty.jsonl").open(mode, encoding="utf-8")
|
||||||
|
lt = (folder / "linked_chat.txt").open(mode, encoding="utf-8")
|
||||||
|
n = await dump_stream(
|
||||||
|
client, linked, client.iter_messages(linked, reverse=True),
|
||||||
|
lj, lp, lt, cache, "linked")
|
||||||
|
for f in (lj, lp, lt):
|
||||||
|
f.close()
|
||||||
|
print(f"вся группа обсуждений: {n} сообщений")
|
||||||
|
|
||||||
|
print(f"\nКаталог: {folder}")
|
||||||
|
finally:
|
||||||
|
await client.disconnect()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
ex.load_dotenv(ROOT / ".env")
|
||||||
|
p = argparse.ArgumentParser(description=__doc__,
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||||
|
p.add_argument("channel", help="@username, id или часть названия канала")
|
||||||
|
p.add_argument("--session", default=str(ROOT / "tg_export.session"))
|
||||||
|
p.add_argument("--out", default=str(ROOT / "out"))
|
||||||
|
p.add_argument("--with-linked", action="store_true",
|
||||||
|
help="дополнительно выгрузить всю группу обсуждений целиком")
|
||||||
|
p.add_argument("--full", action="store_true", help="перезаписать файлы с нуля")
|
||||||
|
args = p.parse_args()
|
||||||
|
ex.install_signal_handlers()
|
||||||
|
try:
|
||||||
|
asyncio.run(main_async(args))
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
sys.exit(130)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,901 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Полная выгрузка истории личного Telegram-аккаунта через Telethon.
|
||||||
|
|
||||||
|
Возможности:
|
||||||
|
* логин по номеру телефона (MTProto, свой api_id/api_hash);
|
||||||
|
* индекс всех диалогов (личные, боты, группы, каналы);
|
||||||
|
* выгрузка всех сообщений в JSONL (сырой `to_dict()` — ничего не теряется)
|
||||||
|
плюс опциональный человекочитаемый .txt;
|
||||||
|
* скачивание медиа с лимитом на размер;
|
||||||
|
* возобновление: повторный запуск догружает только новое;
|
||||||
|
* аккуратная обработка FloodWait и приватных/недоступных чатов.
|
||||||
|
|
||||||
|
Команды:
|
||||||
|
python export.py login — только авторизация (создаёт .session)
|
||||||
|
python export.py list — вывести список диалогов
|
||||||
|
python export.py run — выгрузить историю
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import base64
|
||||||
|
import datetime as dt
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import signal
|
||||||
|
import sys
|
||||||
|
import unicodedata
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from telethon import TelegramClient, errors, utils
|
||||||
|
from telethon.tl import types
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# конфиг
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent
|
||||||
|
|
||||||
|
|
||||||
|
def load_dotenv(path: Path) -> None:
|
||||||
|
"""Минимальный .env-лоадер, чтобы не тащить python-dotenv."""
|
||||||
|
if not path.exists():
|
||||||
|
return
|
||||||
|
for raw in path.read_text(encoding="utf-8").splitlines():
|
||||||
|
line = raw.strip()
|
||||||
|
if not line or line.startswith("#") or "=" not in line:
|
||||||
|
continue
|
||||||
|
key, _, val = line.partition("=")
|
||||||
|
key, val = key.strip(), val.strip().strip("'\"")
|
||||||
|
os.environ.setdefault(key, val)
|
||||||
|
|
||||||
|
|
||||||
|
def require_credentials() -> tuple[int, str]:
|
||||||
|
api_id = os.environ.get("TG_API_ID")
|
||||||
|
api_hash = os.environ.get("TG_API_HASH")
|
||||||
|
if not api_id or not api_hash:
|
||||||
|
sys.exit(
|
||||||
|
"Нет TG_API_ID / TG_API_HASH.\n"
|
||||||
|
"Получи их на https://my.telegram.org → API development tools,\n"
|
||||||
|
f"затем скопируй {ROOT / '.env.example'} в {ROOT / '.env'} и заполни."
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
return int(api_id), api_hash
|
||||||
|
except ValueError:
|
||||||
|
sys.exit(f"TG_API_ID должен быть числом, получено: {api_id!r}")
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# утилиты
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
class Encoder(json.JSONEncoder):
|
||||||
|
"""Сериализует всё, что встречается в Telethon-объектах."""
|
||||||
|
|
||||||
|
def default(self, o): # noqa: D102
|
||||||
|
if isinstance(o, (dt.datetime, dt.date, dt.time)):
|
||||||
|
return o.isoformat()
|
||||||
|
if isinstance(o, dt.timedelta):
|
||||||
|
return o.total_seconds()
|
||||||
|
if isinstance(o, (bytes, bytearray, memoryview)):
|
||||||
|
return base64.b64encode(bytes(o)).decode("ascii")
|
||||||
|
if isinstance(o, set):
|
||||||
|
return sorted(o, key=str)
|
||||||
|
return str(o)
|
||||||
|
|
||||||
|
|
||||||
|
_SLUG_BAD = re.compile(r"[^\w\-. ]+", re.UNICODE)
|
||||||
|
|
||||||
|
|
||||||
|
def slugify(name: str, limit: int = 48) -> str:
|
||||||
|
name = unicodedata.normalize("NFKC", name or "").strip()
|
||||||
|
name = _SLUG_BAD.sub("", name)
|
||||||
|
name = re.sub(r"\s+", "_", name).strip("._-")
|
||||||
|
return (name[:limit] or "unnamed")
|
||||||
|
|
||||||
|
|
||||||
|
def dialog_kind(entity) -> str:
|
||||||
|
if isinstance(entity, types.User):
|
||||||
|
return "bot" if getattr(entity, "bot", False) else "user"
|
||||||
|
if isinstance(entity, types.Chat) or isinstance(entity, types.ChatForbidden):
|
||||||
|
return "group"
|
||||||
|
if isinstance(entity, types.Channel):
|
||||||
|
return "supergroup" if getattr(entity, "megagroup", False) else "channel"
|
||||||
|
return "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
def human_size(n: int | None) -> str:
|
||||||
|
if not n:
|
||||||
|
return "0B"
|
||||||
|
for unit in ("B", "K", "M", "G", "T"):
|
||||||
|
if n < 1024 or unit == "T":
|
||||||
|
return f"{n:.0f}{unit}" if unit == "B" else f"{n:.1f}{unit}"
|
||||||
|
n /= 1024
|
||||||
|
return f"{n:.1f}T"
|
||||||
|
|
||||||
|
|
||||||
|
def parse_date(value: str | None) -> dt.datetime | None:
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
parsed = dt.datetime.fromisoformat(value)
|
||||||
|
except ValueError:
|
||||||
|
sys.exit(f"Не смог разобрать дату {value!r}. Формат: YYYY-MM-DD или YYYY-MM-DDTHH:MM")
|
||||||
|
if parsed.tzinfo is None:
|
||||||
|
parsed = parsed.replace(tzinfo=dt.timezone.utc)
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
STOP = False
|
||||||
|
|
||||||
|
|
||||||
|
def install_signal_handlers() -> None:
|
||||||
|
def handler(signum, frame): # noqa: ARG001
|
||||||
|
global STOP
|
||||||
|
if STOP:
|
||||||
|
sys.exit(130)
|
||||||
|
STOP = True
|
||||||
|
print("\n[!] Останавливаюсь после текущего сообщения; прогресс сохранён. Ctrl-C ещё раз — выход.",
|
||||||
|
file=sys.stderr)
|
||||||
|
|
||||||
|
signal.signal(signal.SIGINT, handler)
|
||||||
|
signal.signal(signal.SIGTERM, handler)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# состояние (для возобновления)
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
class State:
|
||||||
|
def __init__(self, path: Path):
|
||||||
|
self.path = path
|
||||||
|
self.data = {"last_id": 0, "count": 0, "media": 0, "updated_at": None}
|
||||||
|
if path.exists():
|
||||||
|
try:
|
||||||
|
self.data.update(json.loads(path.read_text(encoding="utf-8")))
|
||||||
|
except (ValueError, OSError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
@property
|
||||||
|
def last_id(self) -> int:
|
||||||
|
return int(self.data.get("last_id") or 0)
|
||||||
|
|
||||||
|
def bump(self, msg_id: int, media: int = 0) -> None:
|
||||||
|
self.data["last_id"] = max(self.last_id, int(msg_id))
|
||||||
|
self.data["count"] = int(self.data.get("count") or 0) + 1
|
||||||
|
self.data["media"] = int(self.data.get("media") or 0) + media
|
||||||
|
|
||||||
|
def save(self) -> None:
|
||||||
|
self.data["updated_at"] = dt.datetime.now(dt.timezone.utc).isoformat()
|
||||||
|
tmp = self.path.with_suffix(".tmp")
|
||||||
|
tmp.write_text(json.dumps(self.data, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||||
|
tmp.replace(self.path)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# текстовое представление
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
def render_line(msg, sender_name: str) -> str:
|
||||||
|
when = msg.date.astimezone().strftime("%Y-%m-%d %H:%M:%S") if msg.date else "?"
|
||||||
|
body = (msg.message or "").replace("\n", "\n ")
|
||||||
|
tags = []
|
||||||
|
if msg.media is not None and not isinstance(msg.media, types.MessageMediaWebPage):
|
||||||
|
tags.append(type(msg.media).__name__.replace("MessageMedia", "").lower())
|
||||||
|
if getattr(msg, "reply_to_msg_id", None):
|
||||||
|
tags.append(f"reply→{msg.reply_to_msg_id}")
|
||||||
|
if getattr(msg, "fwd_from", None):
|
||||||
|
tags.append("forwarded")
|
||||||
|
if getattr(msg, "action", None):
|
||||||
|
tags.append(type(msg.action).__name__.replace("MessageAction", "action:"))
|
||||||
|
suffix = f" [{', '.join(tags)}]" if tags else ""
|
||||||
|
return f"[{when}] #{msg.id} {sender_name}:{suffix}\n {body}\n" if body else \
|
||||||
|
f"[{when}] #{msg.id} {sender_name}:{suffix}\n"
|
||||||
|
|
||||||
|
|
||||||
|
async def sender_label(client, msg, cache: dict) -> str:
|
||||||
|
sid = msg.sender_id
|
||||||
|
if sid is None:
|
||||||
|
return "—"
|
||||||
|
if sid in cache:
|
||||||
|
return cache[sid]
|
||||||
|
label = str(sid)
|
||||||
|
try:
|
||||||
|
sender = await msg.get_sender()
|
||||||
|
if sender is not None:
|
||||||
|
if isinstance(sender, types.User):
|
||||||
|
parts = [sender.first_name or "", sender.last_name or ""]
|
||||||
|
label = " ".join(p for p in parts if p) or (sender.username or str(sid))
|
||||||
|
else:
|
||||||
|
label = getattr(sender, "title", None) or str(sid)
|
||||||
|
except (errors.RPCError, ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
cache[sid] = label
|
||||||
|
return label
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# читаемое представление (messages.json)
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
def media_info(msg, record: dict) -> dict | None:
|
||||||
|
media = getattr(msg, "media", None)
|
||||||
|
if media is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if isinstance(media, types.MessageMediaWebPage):
|
||||||
|
url = getattr(getattr(media, "webpage", None), "url", None)
|
||||||
|
return {"type": "link", "url": url} if url else None
|
||||||
|
|
||||||
|
info: dict = {"type": type(media).__name__.replace("MessageMedia", "").lower()}
|
||||||
|
|
||||||
|
handle = getattr(msg, "file", None)
|
||||||
|
for key in ("name", "mime_type", "size", "duration"):
|
||||||
|
val = getattr(handle, key, None)
|
||||||
|
if val:
|
||||||
|
info[key] = round(val, 1) if key == "duration" else val
|
||||||
|
if info.get("size"):
|
||||||
|
info["size_human"] = human_size(info["size"])
|
||||||
|
|
||||||
|
for attr in getattr(getattr(media, "document", None), "attributes", None) or []:
|
||||||
|
if isinstance(attr, types.DocumentAttributeSticker):
|
||||||
|
info["type"] = "sticker"
|
||||||
|
info["emoji"] = attr.alt
|
||||||
|
elif isinstance(attr, types.DocumentAttributeAudio) and attr.voice:
|
||||||
|
info["type"] = "voice"
|
||||||
|
|
||||||
|
if isinstance(media, types.MessageMediaPoll):
|
||||||
|
poll = media.poll
|
||||||
|
info["question"] = getattr(poll.question, "text", poll.question)
|
||||||
|
info["answers"] = [getattr(a.text, "text", a.text) for a in poll.answers]
|
||||||
|
elif isinstance(media, (types.MessageMediaGeo, types.MessageMediaGeoLive)):
|
||||||
|
geo = media.geo
|
||||||
|
info["lat"], info["long"] = getattr(geo, "lat", None), getattr(geo, "long", None)
|
||||||
|
elif isinstance(media, types.MessageMediaContact):
|
||||||
|
info["contact"] = " ".join(
|
||||||
|
p for p in (media.first_name, media.last_name, media.phone_number) if p
|
||||||
|
)
|
||||||
|
|
||||||
|
for key in ("_media_file", "_media_skipped", "_media_error"):
|
||||||
|
if key in record:
|
||||||
|
info[key.lstrip("_").removeprefix("media_")] = record[key]
|
||||||
|
return info
|
||||||
|
|
||||||
|
|
||||||
|
def forward_info(msg) -> dict | None:
|
||||||
|
fwd = getattr(msg, "fwd_from", None)
|
||||||
|
if fwd is None:
|
||||||
|
return None
|
||||||
|
info: dict = {}
|
||||||
|
if fwd.from_name:
|
||||||
|
info["from_name"] = fwd.from_name
|
||||||
|
if fwd.from_id is not None:
|
||||||
|
try:
|
||||||
|
info["from_id"] = utils.get_peer_id(fwd.from_id)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
info["from_id"] = str(fwd.from_id)
|
||||||
|
if fwd.date:
|
||||||
|
info["original_date"] = fwd.date.astimezone().isoformat()
|
||||||
|
if fwd.post_author:
|
||||||
|
info["post_author"] = fwd.post_author
|
||||||
|
return info or None
|
||||||
|
|
||||||
|
|
||||||
|
def reactions_info(msg) -> list | None:
|
||||||
|
results = getattr(getattr(msg, "reactions", None), "results", None) or []
|
||||||
|
out = []
|
||||||
|
for res in results:
|
||||||
|
emoji = getattr(res.reaction, "emoticon", None) \
|
||||||
|
or getattr(res.reaction, "document_id", None)
|
||||||
|
out.append({"reaction": str(emoji), "count": res.count})
|
||||||
|
return out or None
|
||||||
|
|
||||||
|
|
||||||
|
def simplify_message(msg, sender: str, record: dict) -> dict:
|
||||||
|
"""Плоское, читаемое представление сообщения для messages.json."""
|
||||||
|
out: dict = {
|
||||||
|
"id": msg.id,
|
||||||
|
"date": msg.date.astimezone().isoformat() if msg.date else None,
|
||||||
|
"from": sender,
|
||||||
|
"from_id": msg.sender_id,
|
||||||
|
"outgoing": bool(getattr(msg, "out", False)),
|
||||||
|
"text": getattr(msg, "message", "") or "",
|
||||||
|
}
|
||||||
|
if getattr(msg, "reply_to_msg_id", None):
|
||||||
|
out["reply_to"] = msg.reply_to_msg_id
|
||||||
|
if getattr(msg, "edit_date", None):
|
||||||
|
out["edited"] = msg.edit_date.astimezone().isoformat()
|
||||||
|
if fwd := forward_info(msg):
|
||||||
|
out["forwarded"] = fwd
|
||||||
|
if media := media_info(msg, record):
|
||||||
|
out["media"] = media
|
||||||
|
if reactions := reactions_info(msg):
|
||||||
|
out["reactions"] = reactions
|
||||||
|
|
||||||
|
action = getattr(msg, "action", None)
|
||||||
|
if action is not None:
|
||||||
|
out["service_action"] = type(action).__name__.replace("MessageAction", "")
|
||||||
|
if duration := getattr(action, "duration", None):
|
||||||
|
out["service_action_duration_sec"] = duration
|
||||||
|
if getattr(msg, "views", None):
|
||||||
|
out["views"] = msg.views
|
||||||
|
if getattr(msg, "via_bot_id", None):
|
||||||
|
out["via_bot_id"] = msg.via_bot_id
|
||||||
|
if getattr(msg, "pinned", False):
|
||||||
|
out["pinned"] = True
|
||||||
|
if getattr(msg, "grouped_id", None):
|
||||||
|
out["album_id"] = str(msg.grouped_id)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# медиа без файла: качать нечего, для них download_media вернёт None.
|
||||||
|
# MessageMediaContact сюда НЕ входит — Telethon сохраняет контакт как .vcard.
|
||||||
|
NO_FILE_MEDIA = frozenset({
|
||||||
|
"MessageMediaWebPage", "MessageMediaGeo", "MessageMediaGeoLive",
|
||||||
|
"MessageMediaPoll", "MessageMediaDice", "MessageMediaVenue",
|
||||||
|
"MessageMediaGame", "MessageMediaUnsupported", "MessageMediaStory",
|
||||||
|
"MessageMediaInvoice", "MessageMediaGiveaway",
|
||||||
|
"MessageMediaGiveawayResults", "MessageMediaEmpty",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def raw_media_tags(media: dict) -> set[str]:
|
||||||
|
"""Метки медиа из сырого to_dict(): тип, mime, расширение, вид документа.
|
||||||
|
|
||||||
|
Позволяет отсеивать по `--skip-media webp` / `sticker` / `video` ещё до
|
||||||
|
запроса к API — по данным, которые уже лежат в messages.jsonl.
|
||||||
|
"""
|
||||||
|
if not media:
|
||||||
|
return set()
|
||||||
|
kind = media.get("_", "")
|
||||||
|
tags = {kind.replace("MessageMedia", "").lower()}
|
||||||
|
if kind == "MessageMediaPhoto":
|
||||||
|
tags |= {"photo", "jpg", "jpeg", "image/jpeg"}
|
||||||
|
|
||||||
|
doc = media.get("document") or {}
|
||||||
|
mime = (doc.get("mime_type") or "").lower()
|
||||||
|
if mime:
|
||||||
|
tags.add(mime)
|
||||||
|
tags.add(mime.rsplit("/", 1)[-1])
|
||||||
|
|
||||||
|
for attr in doc.get("attributes") or []:
|
||||||
|
name = attr.get("_", "")
|
||||||
|
if name == "DocumentAttributeSticker":
|
||||||
|
tags.add("sticker")
|
||||||
|
elif name == "DocumentAttributeAudio":
|
||||||
|
tags.add("voice" if attr.get("voice") else "audio")
|
||||||
|
elif name == "DocumentAttributeVideo":
|
||||||
|
tags.add("round" if attr.get("round_message") else "video")
|
||||||
|
elif name == "DocumentAttributeAnimated":
|
||||||
|
tags.add("gif")
|
||||||
|
elif name == "DocumentAttributeFilename":
|
||||||
|
filename = attr.get("file_name") or ""
|
||||||
|
if "." in filename:
|
||||||
|
tags.add(filename.rsplit(".", 1)[-1].lower())
|
||||||
|
return tags
|
||||||
|
|
||||||
|
|
||||||
|
def has_downloadable_media(msg) -> bool:
|
||||||
|
media = getattr(msg, "media", None)
|
||||||
|
return media is not None and type(media).__name__ not in NO_FILE_MEDIA
|
||||||
|
|
||||||
|
|
||||||
|
def load_media_index(folder: Path) -> dict[str, dict]:
|
||||||
|
path = folder / "media_index.json"
|
||||||
|
if not path.exists():
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
return json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except (ValueError, OSError):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def assemble_pretty(folder: Path, header: dict) -> int:
|
||||||
|
"""Собирает messages.json из накопленного _pretty.jsonl (устойчиво к докачке)."""
|
||||||
|
src = folder / "_pretty.jsonl"
|
||||||
|
if not src.exists():
|
||||||
|
return 0
|
||||||
|
by_id: dict[int, dict] = {}
|
||||||
|
with src.open(encoding="utf-8") as handle:
|
||||||
|
for line in handle:
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
rec = json.loads(line)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
by_id[rec["id"]] = rec
|
||||||
|
|
||||||
|
# подмешиваем результаты догрузки медиа, если она уже прошла
|
||||||
|
for msg_id, entry in load_media_index(folder).items():
|
||||||
|
rec = by_id.get(int(msg_id))
|
||||||
|
if rec is None or "media" not in rec:
|
||||||
|
continue
|
||||||
|
# не ссылаемся на файлы, которых уже нет на диске (удалили вручную и т.п.)
|
||||||
|
if "file" in entry and not (folder / entry["file"]).exists():
|
||||||
|
continue
|
||||||
|
rec["media"].update({k: v for k, v in entry.items() if v is not None})
|
||||||
|
|
||||||
|
messages = [by_id[key] for key in sorted(by_id)]
|
||||||
|
payload = {
|
||||||
|
"chat": header,
|
||||||
|
"messages_count": len(messages),
|
||||||
|
"first_message_date": messages[0]["date"] if messages else None,
|
||||||
|
"last_message_date": messages[-1]["date"] if messages else None,
|
||||||
|
"messages": messages,
|
||||||
|
}
|
||||||
|
(folder / "messages.json").write_text(
|
||||||
|
json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8"
|
||||||
|
)
|
||||||
|
return len(messages)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# выгрузка одного диалога
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
async def export_dialog(client, dialog, out_root: Path, args) -> dict:
|
||||||
|
entity = dialog.entity
|
||||||
|
kind = dialog_kind(entity)
|
||||||
|
folder = out_root / "chats" / f"{dialog.id}_{slugify(dialog.name)}"
|
||||||
|
media_dir = folder / "media"
|
||||||
|
folder.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
state = State(folder / "_state.json")
|
||||||
|
start_from = 0 if args.full else state.last_id
|
||||||
|
|
||||||
|
header = {
|
||||||
|
"id": dialog.id,
|
||||||
|
"name": dialog.name,
|
||||||
|
"kind": kind,
|
||||||
|
"username": getattr(entity, "username", None),
|
||||||
|
"phone": getattr(entity, "phone", None),
|
||||||
|
"is_archived": bool(getattr(dialog, "archived", False)),
|
||||||
|
"exported_at": dt.datetime.now(dt.timezone.utc).isoformat(),
|
||||||
|
}
|
||||||
|
(folder / "meta.json").write_text(
|
||||||
|
json.dumps({**header, "entity": entity.to_dict()},
|
||||||
|
ensure_ascii=False, indent=2, cls=Encoder),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
excluded_types = {t.lower().lstrip(".") for t in args.skip_media}
|
||||||
|
mode = "w" if args.full else "a"
|
||||||
|
jsonl = (folder / "messages.jsonl").open(mode, encoding="utf-8")
|
||||||
|
txt = (folder / "messages.txt").open(mode, encoding="utf-8") if args.txt else None
|
||||||
|
pretty = (folder / "_pretty.jsonl").open(mode, encoding="utf-8") if args.pretty else None
|
||||||
|
cache: dict = {}
|
||||||
|
new_msgs = 0
|
||||||
|
new_media = 0
|
||||||
|
media_bytes = 0
|
||||||
|
errors_count = 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
async for msg in client.iter_messages(
|
||||||
|
entity,
|
||||||
|
reverse=True, # от старых к новым — так работает докачка
|
||||||
|
offset_id=start_from,
|
||||||
|
offset_date=args._since,
|
||||||
|
limit=args.limit,
|
||||||
|
wait_time=args.wait or None,
|
||||||
|
):
|
||||||
|
if STOP:
|
||||||
|
break
|
||||||
|
if args._until and msg.date and msg.date > args._until:
|
||||||
|
break
|
||||||
|
|
||||||
|
record = msg.to_dict()
|
||||||
|
|
||||||
|
if args.media and has_downloadable_media(msg):
|
||||||
|
size = getattr(getattr(msg, "file", None), "size", None) or 0
|
||||||
|
hit = raw_media_tags(record.get("media") or {}) & excluded_types
|
||||||
|
if hit:
|
||||||
|
record["_media_skipped"] = f"type_excluded:{','.join(sorted(hit))}"
|
||||||
|
elif args.max_media_mb and size > args.max_media_mb * 1024 * 1024:
|
||||||
|
record["_media_skipped"] = f"too_large:{human_size(size)}"
|
||||||
|
else:
|
||||||
|
media_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
try:
|
||||||
|
path = await client.download_media(msg, file=str(media_dir / f"{msg.id}"))
|
||||||
|
if path:
|
||||||
|
record["_media_file"] = str(Path(path).relative_to(folder))
|
||||||
|
new_media += 1
|
||||||
|
media_bytes += size or Path(path).stat().st_size
|
||||||
|
except errors.FloodWaitError as exc:
|
||||||
|
print(f" FloodWait {exc.seconds}s на медиа #{msg.id} — жду", file=sys.stderr)
|
||||||
|
await asyncio.sleep(exc.seconds + 1)
|
||||||
|
except (errors.RPCError, OSError, ValueError) as exc:
|
||||||
|
record["_media_error"] = f"{type(exc).__name__}: {exc}"
|
||||||
|
errors_count += 1
|
||||||
|
|
||||||
|
jsonl.write(json.dumps(record, ensure_ascii=False, cls=Encoder) + "\n")
|
||||||
|
|
||||||
|
label = await sender_label(client, msg, cache) if (txt or pretty) else ""
|
||||||
|
if txt is not None:
|
||||||
|
txt.write(render_line(msg, label))
|
||||||
|
if pretty is not None:
|
||||||
|
pretty.write(json.dumps(simplify_message(msg, label, record),
|
||||||
|
ensure_ascii=False, cls=Encoder) + "\n")
|
||||||
|
|
||||||
|
state.bump(msg.id, media=1 if "_media_file" in record else 0)
|
||||||
|
new_msgs += 1
|
||||||
|
if new_msgs % 500 == 0:
|
||||||
|
jsonl.flush()
|
||||||
|
for stream in (txt, pretty):
|
||||||
|
if stream is not None:
|
||||||
|
stream.flush()
|
||||||
|
state.save()
|
||||||
|
print(f" … {new_msgs} новых (всего {state.data['count']})", flush=True)
|
||||||
|
finally:
|
||||||
|
jsonl.close()
|
||||||
|
for stream in (txt, pretty):
|
||||||
|
if stream is not None:
|
||||||
|
stream.close()
|
||||||
|
state.save()
|
||||||
|
if args.pretty:
|
||||||
|
assemble_pretty(folder, header)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": dialog.id,
|
||||||
|
"name": dialog.name,
|
||||||
|
"kind": kind,
|
||||||
|
"new_messages": new_msgs,
|
||||||
|
"total_messages": state.data["count"],
|
||||||
|
"new_media": new_media,
|
||||||
|
"media_bytes": media_bytes,
|
||||||
|
"media_errors": errors_count,
|
||||||
|
"folder": str(folder.relative_to(out_root)),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# отбор диалогов
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
def dialog_matches(dialog, args) -> bool:
|
||||||
|
kind = dialog_kind(dialog.entity)
|
||||||
|
if args.kinds and kind not in args.kinds:
|
||||||
|
return False
|
||||||
|
if not args.archived and getattr(dialog, "archived", False):
|
||||||
|
return False
|
||||||
|
|
||||||
|
if args.skip:
|
||||||
|
for needle in args.skip:
|
||||||
|
if _needle_hits(dialog, needle):
|
||||||
|
return False
|
||||||
|
if args.only:
|
||||||
|
return any(_needle_hits(dialog, needle) for needle in args.only)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _needle_hits(dialog, needle: str) -> bool:
|
||||||
|
needle = needle.strip()
|
||||||
|
if not needle:
|
||||||
|
return False
|
||||||
|
if needle.lstrip("-").isdigit() and int(needle) == dialog.id:
|
||||||
|
return True
|
||||||
|
username = getattr(dialog.entity, "username", None) or ""
|
||||||
|
if needle.lstrip("@").lower() == username.lower() and username:
|
||||||
|
return True
|
||||||
|
return needle.lower() in (dialog.name or "").lower()
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# команды
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
async def cmd_login(client, args) -> None: # noqa: ARG001
|
||||||
|
me = await client.get_me()
|
||||||
|
print(f"OK: {me.first_name or ''} {me.last_name or ''} "
|
||||||
|
f"(@{me.username or '—'}, id={me.id}). Сессия: {args.session}")
|
||||||
|
|
||||||
|
|
||||||
|
async def cmd_list(client, args) -> None:
|
||||||
|
rows = []
|
||||||
|
async for dialog in client.iter_dialogs():
|
||||||
|
if not dialog_matches(dialog, args):
|
||||||
|
continue
|
||||||
|
rows.append((dialog.id, dialog_kind(dialog.entity), dialog.name or "",
|
||||||
|
getattr(dialog.entity, "username", None) or "",
|
||||||
|
getattr(dialog, "unread_count", 0)))
|
||||||
|
width = max((len(r[2]) for r in rows), default=10)
|
||||||
|
print(f"{'ID':>15} {'ТИП':<10} {'НАЗВАНИЕ':<{min(width, 50)}} USERNAME")
|
||||||
|
for did, kind, name, username, _ in rows:
|
||||||
|
print(f"{did:>15} {kind:<10} {name[:50]:<{min(width, 50)}} "
|
||||||
|
f"{'@' + username if username else ''}")
|
||||||
|
print(f"\nвсего: {len(rows)}")
|
||||||
|
|
||||||
|
|
||||||
|
async def cmd_media(client, args) -> None:
|
||||||
|
"""Догружает медиа к уже выгруженным сообщениям.
|
||||||
|
|
||||||
|
Идёт не по API-истории, а по локальному messages.jsonl, поэтому:
|
||||||
|
* не зависит от прогресса `run` и не мешает ему;
|
||||||
|
* инкрементальна — уже скачанное фиксируется в media_index.json;
|
||||||
|
* может гоняться повторно, пока `run` дописывает историю.
|
||||||
|
"""
|
||||||
|
out_root = Path(args.out).expanduser().resolve()
|
||||||
|
folders = sorted(p for p in (out_root / "chats").glob("*") if p.is_dir())
|
||||||
|
if not folders:
|
||||||
|
sys.exit(f"В {out_root / 'chats'} нет выгруженных чатов — сначала запусти `run`.")
|
||||||
|
|
||||||
|
limit_bytes = int(args.max_media_mb * 1024 * 1024) if args.max_media_mb else 0
|
||||||
|
grand_new = grand_bytes = grand_skip = grand_err = 0
|
||||||
|
|
||||||
|
for folder in folders:
|
||||||
|
try:
|
||||||
|
header = json.loads((folder / "meta.json").read_text(encoding="utf-8"))
|
||||||
|
except (ValueError, OSError):
|
||||||
|
print(f"[!] {folder.name}: нет читаемого meta.json — пропуск", file=sys.stderr)
|
||||||
|
continue
|
||||||
|
|
||||||
|
needles = args.only or []
|
||||||
|
if needles and not any(
|
||||||
|
n.lstrip("@").lower() in ((header.get("username") or "").lower(),
|
||||||
|
str(header.get("id")))
|
||||||
|
or n.lower() in (header.get("name") or "").lower()
|
||||||
|
for n in needles
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
|
||||||
|
raw = folder / "messages.jsonl"
|
||||||
|
if not raw.exists():
|
||||||
|
continue
|
||||||
|
|
||||||
|
index = load_media_index(folder)
|
||||||
|
excluded = {t.lower().lstrip(".") for t in args.skip_media}
|
||||||
|
todo: list[int] = []
|
||||||
|
by_type_excluded = 0
|
||||||
|
with raw.open(encoding="utf-8") as handle:
|
||||||
|
for line in handle:
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
rec = json.loads(line)
|
||||||
|
except ValueError:
|
||||||
|
continue # хвост может быть недописан, если `run` ещё идёт
|
||||||
|
media = rec.get("media")
|
||||||
|
if not media or media.get("_") in NO_FILE_MEDIA:
|
||||||
|
index.pop(str(rec["id"]), None) # чистим ложные nothing_to_download
|
||||||
|
continue
|
||||||
|
if excluded and (raw_media_tags(media) & excluded):
|
||||||
|
index.pop(str(rec["id"]), None) # забываем то, что решили не хранить
|
||||||
|
by_type_excluded += 1
|
||||||
|
continue
|
||||||
|
if rec.get("_media_file"):
|
||||||
|
continue
|
||||||
|
# готово только то, у чего реально есть файл: записи skipped/error
|
||||||
|
# переспрашиваются — иначе поднятый --max-media-mb ничего не даст
|
||||||
|
if "file" in index.get(str(rec["id"]), {}):
|
||||||
|
continue
|
||||||
|
todo.append(rec["id"])
|
||||||
|
|
||||||
|
print(f"\n{folder.name}: к догрузке {len(todo)} медиа "
|
||||||
|
f"(уже есть {len(index)}"
|
||||||
|
+ (f", исключено по типу {by_type_excluded}" if by_type_excluded else "")
|
||||||
|
+ ")", flush=True)
|
||||||
|
if not todo:
|
||||||
|
continue
|
||||||
|
|
||||||
|
entity = await client.get_entity(int(header["id"]))
|
||||||
|
media_dir = folder / "media"
|
||||||
|
media_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
sem = asyncio.Semaphore(max(1, args.jobs))
|
||||||
|
done = new = skipped = failed = 0
|
||||||
|
got_bytes = 0
|
||||||
|
|
||||||
|
async def fetch(msg) -> None:
|
||||||
|
nonlocal done, new, skipped, failed, got_bytes
|
||||||
|
async with sem:
|
||||||
|
if msg is None or STOP:
|
||||||
|
return
|
||||||
|
size = getattr(getattr(msg, "file", None), "size", None) or 0
|
||||||
|
if limit_bytes and size > limit_bytes:
|
||||||
|
index[str(msg.id)] = {"skipped": f"too_large:{human_size(size)}"}
|
||||||
|
skipped += 1
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
path = await client.download_media(
|
||||||
|
msg, file=str(media_dir / f"{msg.id}"))
|
||||||
|
if path:
|
||||||
|
index[str(msg.id)] = {
|
||||||
|
"file": str(Path(path).relative_to(folder))}
|
||||||
|
new += 1
|
||||||
|
got_bytes += size or Path(path).stat().st_size
|
||||||
|
else:
|
||||||
|
index[str(msg.id)] = {"error": "nothing_to_download"}
|
||||||
|
failed += 1
|
||||||
|
except errors.FloodWaitError as exc:
|
||||||
|
print(f" FloodWait {exc.seconds}s — жду", file=sys.stderr, flush=True)
|
||||||
|
await asyncio.sleep(exc.seconds + 1)
|
||||||
|
return
|
||||||
|
except (errors.RPCError, OSError, ValueError) as exc:
|
||||||
|
index[str(msg.id)] = {"error": f"{type(exc).__name__}: {exc}"}
|
||||||
|
failed += 1
|
||||||
|
done += 1
|
||||||
|
if done % 200 == 0:
|
||||||
|
(folder / "media_index.json").write_text(
|
||||||
|
json.dumps(index, ensure_ascii=False, indent=1), encoding="utf-8")
|
||||||
|
print(f" … {done}/{len(todo)} +{new} файлов "
|
||||||
|
f"({human_size(got_bytes)}), крупных пропущено {skipped}, "
|
||||||
|
f"ошибок {failed}", flush=True)
|
||||||
|
|
||||||
|
for start in range(0, len(todo), 100):
|
||||||
|
if STOP:
|
||||||
|
break
|
||||||
|
chunk = todo[start:start + 100]
|
||||||
|
try:
|
||||||
|
msgs = await client.get_messages(entity, ids=chunk)
|
||||||
|
except errors.FloodWaitError as exc:
|
||||||
|
print(f" FloodWait {exc.seconds}s на выборке — жду", file=sys.stderr, flush=True)
|
||||||
|
await asyncio.sleep(exc.seconds + 1)
|
||||||
|
msgs = await client.get_messages(entity, ids=chunk)
|
||||||
|
await asyncio.gather(*(fetch(m) for m in msgs))
|
||||||
|
|
||||||
|
(folder / "media_index.json").write_text(
|
||||||
|
json.dumps(index, ensure_ascii=False, indent=1), encoding="utf-8")
|
||||||
|
if args.pretty:
|
||||||
|
assemble_pretty(folder, header)
|
||||||
|
print(f" итого: +{new} файлов ({human_size(got_bytes)}), "
|
||||||
|
f"крупных пропущено {skipped}, ошибок {failed}", flush=True)
|
||||||
|
grand_new += new
|
||||||
|
grand_bytes += got_bytes
|
||||||
|
grand_skip += skipped
|
||||||
|
grand_err += failed
|
||||||
|
|
||||||
|
print(f"\nМедиа{' (прервано)' if STOP else ''}: +{grand_new} файлов "
|
||||||
|
f"({human_size(grand_bytes)}), пропущено по размеру {grand_skip}, "
|
||||||
|
f"ошибок {grand_err}.")
|
||||||
|
|
||||||
|
|
||||||
|
async def cmd_run(client, args) -> None:
|
||||||
|
out_root = Path(args.out).expanduser().resolve()
|
||||||
|
(out_root / "chats").mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
dialogs = []
|
||||||
|
async for dialog in client.iter_dialogs():
|
||||||
|
if dialog_matches(dialog, args):
|
||||||
|
dialogs.append(dialog)
|
||||||
|
|
||||||
|
me = await client.get_me()
|
||||||
|
print(f"Аккаунт: @{me.username or me.id} · диалогов к выгрузке: {len(dialogs)} · вывод: {out_root}\n")
|
||||||
|
|
||||||
|
index, summary = [], []
|
||||||
|
for n, dialog in enumerate(dialogs, 1):
|
||||||
|
if STOP:
|
||||||
|
break
|
||||||
|
kind = dialog_kind(dialog.entity)
|
||||||
|
print(f"[{n}/{len(dialogs)}] {kind:<10} {dialog.name} (id={dialog.id})", flush=True)
|
||||||
|
try:
|
||||||
|
result = await export_dialog(client, dialog, out_root, args)
|
||||||
|
except errors.FloodWaitError as exc:
|
||||||
|
print(f" FloodWait {exc.seconds}s — жду и повторяю", file=sys.stderr)
|
||||||
|
await asyncio.sleep(exc.seconds + 1)
|
||||||
|
result = await export_dialog(client, dialog, out_root, args)
|
||||||
|
except (errors.ChannelPrivateError, errors.ChatAdminRequiredError,
|
||||||
|
errors.UserBannedInChannelError) as exc:
|
||||||
|
print(f" пропуск: {type(exc).__name__}", file=sys.stderr)
|
||||||
|
result = {"id": dialog.id, "name": dialog.name, "kind": kind,
|
||||||
|
"error": type(exc).__name__}
|
||||||
|
except errors.RPCError as exc:
|
||||||
|
print(f" ошибка: {type(exc).__name__}: {exc}", file=sys.stderr)
|
||||||
|
result = {"id": dialog.id, "name": dialog.name, "kind": kind,
|
||||||
|
"error": f"{type(exc).__name__}: {exc}"}
|
||||||
|
|
||||||
|
index.append(result)
|
||||||
|
summary.append(result)
|
||||||
|
if "error" not in result:
|
||||||
|
print(f" +{result['new_messages']} сообщений, +{result['new_media']} медиа "
|
||||||
|
f"({human_size(result['media_bytes'])}), всего {result['total_messages']}",
|
||||||
|
flush=True)
|
||||||
|
|
||||||
|
(out_root / "dialogs.json").write_text(
|
||||||
|
json.dumps(index, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||||
|
|
||||||
|
total_msgs = sum(r.get("new_messages", 0) for r in summary)
|
||||||
|
total_media = sum(r.get("new_media", 0) for r in summary)
|
||||||
|
total_bytes = sum(r.get("media_bytes", 0) for r in summary)
|
||||||
|
failed = [r for r in summary if "error" in r]
|
||||||
|
print(f"\nГотово{' (прервано)' if STOP else ''}: "
|
||||||
|
f"{len(summary)} диалогов, +{total_msgs} сообщений, "
|
||||||
|
f"+{total_media} медиа ({human_size(total_bytes)}).")
|
||||||
|
if failed:
|
||||||
|
print(f"Недоступны ({len(failed)}): " +
|
||||||
|
", ".join(f"{r['name']} [{r['error']}]" for r in failed[:10]) +
|
||||||
|
(" …" if len(failed) > 10 else ""))
|
||||||
|
print(f"Каталог: {out_root}")
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# CLI
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
KINDS = ("user", "bot", "group", "supergroup", "channel")
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
|
p = argparse.ArgumentParser(
|
||||||
|
description="Выгрузка истории личного Telegram через Telethon",
|
||||||
|
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
|
||||||
|
)
|
||||||
|
p.add_argument("command", choices=("login", "list", "run", "media"))
|
||||||
|
p.add_argument("--session", default=str(ROOT / "tg_export.session"),
|
||||||
|
help="файл сессии Telethon")
|
||||||
|
p.add_argument("--out", default=str(ROOT / "out"), help="каталог для выгрузки")
|
||||||
|
|
||||||
|
sel = p.add_argument_group("отбор диалогов")
|
||||||
|
sel.add_argument("--kinds", nargs="*", choices=KINDS, default=list(KINDS),
|
||||||
|
help="какие типы чатов выгружать")
|
||||||
|
sel.add_argument("--only", nargs="*", default=[], metavar="NEEDLE",
|
||||||
|
help="только эти чаты: id, @username или часть названия")
|
||||||
|
sel.add_argument("--skip", nargs="*", default=[], metavar="NEEDLE",
|
||||||
|
help="исключить чаты (id / @username / часть названия)")
|
||||||
|
sel.add_argument("--archived", action="store_true", help="включить архив")
|
||||||
|
|
||||||
|
rng = p.add_argument_group("границы истории")
|
||||||
|
rng.add_argument("--since", metavar="YYYY-MM-DD", help="только сообщения не раньше даты")
|
||||||
|
rng.add_argument("--until", metavar="YYYY-MM-DD", help="только сообщения не позже даты")
|
||||||
|
rng.add_argument("--limit", type=int, default=None,
|
||||||
|
help="максимум сообщений на чат (для пробного прогона)")
|
||||||
|
rng.add_argument("--full", action="store_true",
|
||||||
|
help="игнорировать сохранённый прогресс и перезаписать всё с нуля")
|
||||||
|
|
||||||
|
med = p.add_argument_group("медиа и вывод")
|
||||||
|
med.add_argument("--media", action=argparse.BooleanOptionalAction, default=True,
|
||||||
|
help="скачивать медиафайлы")
|
||||||
|
med.add_argument("--max-media-mb", type=float, default=50,
|
||||||
|
help="не качать файлы крупнее (0 — без лимита)")
|
||||||
|
med.add_argument("--skip-media", nargs="*", default=[], metavar="TAG",
|
||||||
|
help="не качать медиа с такими метками: расширение (webp, mp4), "
|
||||||
|
"mime (image/webp) или вид (sticker, video, voice, round, gif)")
|
||||||
|
med.add_argument("--pretty", action=argparse.BooleanOptionalAction, default=True,
|
||||||
|
help="писать читаемый messages.json (плоские поля, отступы)")
|
||||||
|
med.add_argument("--txt", action=argparse.BooleanOptionalAction, default=True,
|
||||||
|
help="дополнительно писать читаемый messages.txt")
|
||||||
|
med.add_argument("--wait", type=float, default=0.0,
|
||||||
|
help="пауза между запросами истории, сек (мягче к лимитам)")
|
||||||
|
med.add_argument("--jobs", type=int, default=4,
|
||||||
|
help="параллельных загрузок в команде media")
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
async def main_async(args) -> None:
|
||||||
|
api_id, api_hash = require_credentials()
|
||||||
|
client = TelegramClient(args.session, api_id, api_hash)
|
||||||
|
client.flood_sleep_threshold = 24 * 60 * 60 # сам ждёт длинные FloodWait
|
||||||
|
|
||||||
|
await client.start(
|
||||||
|
phone=lambda: input("Номер телефона (в формате +7...): ").strip(),
|
||||||
|
password=lambda: input("Пароль двухфакторной аутентификации: ").strip(),
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
handler = {"login": cmd_login, "list": cmd_list,
|
||||||
|
"run": cmd_run, "media": cmd_media}[args.command]
|
||||||
|
await handler(client, args)
|
||||||
|
finally:
|
||||||
|
await client.disconnect()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
load_dotenv(ROOT / ".env")
|
||||||
|
args = build_parser().parse_args()
|
||||||
|
args._since = parse_date(args.since)
|
||||||
|
args._until = parse_date(args.until)
|
||||||
|
if args.max_media_mb <= 0:
|
||||||
|
args.max_media_mb = 0
|
||||||
|
install_signal_handlers()
|
||||||
|
try:
|
||||||
|
asyncio.run(main_async(args))
|
||||||
|
except (errors.PhoneNumberInvalidError, errors.SessionPasswordNeededError) as exc:
|
||||||
|
sys.exit(f"Ошибка авторизации: {type(exc).__name__}: {exc}")
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
sys.exit(130)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Выгрузка публичной группы/канала, в которой мы не состоим.
|
||||||
|
|
||||||
|
`export.py run` ходит по iter_dialogs и потому видит только свои чаты.
|
||||||
|
Здесь сущность резолвится по @username и история читается напрямую.
|
||||||
|
|
||||||
|
uv run fetch_public.py @cyschools --since 2026-03-04
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import datetime as dt
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from telethon import TelegramClient
|
||||||
|
|
||||||
|
import export as ex
|
||||||
|
|
||||||
|
ex.load_dotenv(ex.ROOT / ".env")
|
||||||
|
|
||||||
|
|
||||||
|
async def main_async(args) -> None:
|
||||||
|
api_id, api_hash = ex.require_credentials()
|
||||||
|
client = TelegramClient(args.session, api_id, api_hash)
|
||||||
|
client.flood_sleep_threshold = 24 * 60 * 60
|
||||||
|
await client.start()
|
||||||
|
try:
|
||||||
|
entity = await client.get_entity(args.chat)
|
||||||
|
dialog_id = int(f"-100{entity.id}")
|
||||||
|
folder = Path(args.out) / "chats" / f"{dialog_id}_{ex.slugify(getattr(entity, 'title', args.chat))}"
|
||||||
|
folder.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
since = dt.datetime.fromisoformat(args.since).replace(tzinfo=dt.timezone.utc)
|
||||||
|
(folder / "meta.json").write_text(json.dumps({
|
||||||
|
"id": dialog_id,
|
||||||
|
"name": getattr(entity, "title", None),
|
||||||
|
"kind": ex.dialog_kind(entity),
|
||||||
|
"username": getattr(entity, "username", None),
|
||||||
|
"fetched_since": args.since,
|
||||||
|
}, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||||
|
|
||||||
|
cache: dict = {}
|
||||||
|
n = 0
|
||||||
|
with (folder / "messages.jsonl").open("w", encoding="utf-8") as jsonl, \
|
||||||
|
(folder / "messages_pretty.jsonl").open("w", encoding="utf-8") as pretty:
|
||||||
|
async for msg in client.iter_messages(entity, offset_date=since, reverse=True):
|
||||||
|
record = msg.to_dict()
|
||||||
|
sender = await ex.sender_label(client, msg, cache)
|
||||||
|
jsonl.write(json.dumps(record, ensure_ascii=False, cls=ex.Encoder) + "\n")
|
||||||
|
pretty.write(json.dumps(ex.simplify_message(msg, sender, record),
|
||||||
|
ensure_ascii=False, cls=ex.Encoder) + "\n")
|
||||||
|
n += 1
|
||||||
|
if n % 1000 == 0:
|
||||||
|
print(f" … {n} ({msg.date:%Y-%m-%d})", flush=True)
|
||||||
|
print(f"{folder.name}: {n} сообщений")
|
||||||
|
finally:
|
||||||
|
await client.disconnect()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
p = argparse.ArgumentParser()
|
||||||
|
p.add_argument("chat")
|
||||||
|
p.add_argument("--since", required=True)
|
||||||
|
p.add_argument("--out", default=str(ex.ROOT / "out"))
|
||||||
|
p.add_argument("--session", default=str(ex.ROOT / "tg_export.session"))
|
||||||
|
asyncio.run(main_async(p.parse_args()))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
[project]
|
||||||
|
name = "tg-export"
|
||||||
|
version = "0.1.0"
|
||||||
|
requires-python = ">=3.13"
|
||||||
|
description = "Полная выгрузка истории личного Telegram через Telethon"
|
||||||
|
dependencies = [
|
||||||
|
"cryptg>=0.6.0",
|
||||||
|
"telethon>=1.44.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.uv]
|
||||||
|
package = false
|
||||||
@@ -0,0 +1,381 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""tg — поиск по выгрузке Telegram (группы второго аккаунта владельца) и ежедневная сводка по темам.
|
||||||
|
|
||||||
|
tg search ЗАПРОС [--chat ЧАСТЬ_НАЗВАНИЯ] [--since 30d|2026-01-01] [-n 12]
|
||||||
|
гибридный поиск (слова + смысл) по кускам переписки, со ссылками t.me на сообщения
|
||||||
|
tg topic add "ТЕМА" [--query "что искать"] | list | del НОМЕР
|
||||||
|
темы ежедневной сводки (query — поисковая формулировка, по умолчанию — сама тема)
|
||||||
|
tg report [--hours 24] [--dry] сводка по темам за период → владельцу в Telegram (--dry — только напечатать)
|
||||||
|
tg stats сколько чатов/сообщений/кусков, когда обновлялось
|
||||||
|
tg index (сервис tg-export, юзер tgexport) — дописать в индекс новые сообщения выгрузки
|
||||||
|
|
||||||
|
Индекс — /mnt/backup/tg/index.db (SQLite: FTS5 + эмбеддинги potion-multilingual-128M), пишет tgexport,
|
||||||
|
читают все из группы astra. Кусок = подряд идущие сообщения одного чата с паузами < 20 мин (до ~800 знаков).
|
||||||
|
Темы и состояние сводки — /var/lib/tg-report/ (astra). Сводку пишет Codex (подписка Astra), не Claude.
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import datetime as dt
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sqlite3
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
OUT = Path(os.environ.get("TG_OUT", "/mnt/backup/tg"))
|
||||||
|
DB = Path(os.environ.get("TG_INDEX", OUT / "index.db"))
|
||||||
|
MODELS = os.environ.get("TG_MODELS", "/opt/tg-export/models")
|
||||||
|
# potion — статические эмбеддинги (model2vec): на Pi 4 в ~16 раз быстрее MiniLM при близком качестве поиска
|
||||||
|
# (сравнение 2026-10-18 на 8 тыс. сообщений: 57 с против 915 с), без ограничения длины текста
|
||||||
|
EMB_MODEL = os.environ.get("TG_EMB_MODEL", "minishlab/potion-multilingual-128M")
|
||||||
|
REPORT = Path(os.environ.get("TG_REPORT_DIR", "/var/lib/tg-report"))
|
||||||
|
OUTBOX = Path(os.environ.get("ASTRA_OUTBOX", "/var/lib/astra-google/outbox"))
|
||||||
|
TZ = ZoneInfo("Asia/Nicosia")
|
||||||
|
GAP = 20 * 60 # пауза, после которой начинается новый кусок
|
||||||
|
CHUNK_CHARS = int(os.environ.get("TG_CHUNK_CHARS", "800"))
|
||||||
|
TG_MAX = 3800 # одно сообщение Telegram (лимит 4096) — сводку режем на части
|
||||||
|
|
||||||
|
_emb = None
|
||||||
|
|
||||||
|
|
||||||
|
def embed(texts):
|
||||||
|
global _emb
|
||||||
|
import numpy as np
|
||||||
|
if _emb is None:
|
||||||
|
from fastembed import TextEmbedding
|
||||||
|
_emb = TextEmbedding(EMB_MODEL, cache_dir=MODELS, threads=4)
|
||||||
|
v = np.array(list(_emb.embed(texts, batch_size=32)), dtype=np.float32)
|
||||||
|
v /= np.linalg.norm(v, axis=1, keepdims=True) + 1e-9
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
def db(write=False):
|
||||||
|
if write:
|
||||||
|
DB.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
c = sqlite3.connect(DB, timeout=120)
|
||||||
|
# rollback-журнал, не WAL: читателям (astra) не нужна запись в -shm/-wal
|
||||||
|
c.execute("PRAGMA journal_mode=DELETE")
|
||||||
|
c.executescript("""
|
||||||
|
CREATE TABLE IF NOT EXISTS chat(id INTEGER PRIMARY KEY, title TEXT, username TEXT, kind TEXT, folder TEXT, last_id INTEGER DEFAULT 0);
|
||||||
|
CREATE TABLE IF NOT EXISTS chunk(id INTEGER PRIMARY KEY, chat_id INTEGER, first_id INTEGER, last_id INTEGER,
|
||||||
|
ts0 INTEGER, ts1 INTEGER, n INTEGER, text TEXT, emb BLOB);
|
||||||
|
CREATE INDEX IF NOT EXISTS chunk_chat ON chunk(chat_id, last_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS chunk_ts ON chunk(ts1);
|
||||||
|
CREATE VIRTUAL TABLE IF NOT EXISTS chunk_fts USING fts5(text, content='chunk', content_rowid='id',
|
||||||
|
tokenize='unicode61 remove_diacritics 2');
|
||||||
|
CREATE TRIGGER IF NOT EXISTS chunk_ai AFTER INSERT ON chunk BEGIN
|
||||||
|
INSERT INTO chunk_fts(rowid, text) VALUES (new.id, new.text); END;
|
||||||
|
CREATE TRIGGER IF NOT EXISTS chunk_ad AFTER DELETE ON chunk BEGIN
|
||||||
|
INSERT INTO chunk_fts(chunk_fts, rowid, text) VALUES ('delete', old.id, old.text); END;
|
||||||
|
CREATE TABLE IF NOT EXISTS meta(k TEXT PRIMARY KEY, v TEXT);
|
||||||
|
""")
|
||||||
|
else:
|
||||||
|
if not DB.exists():
|
||||||
|
raise SystemExit("индекса ещё нет — выгрузка Telegram не запускалась (tg-export)")
|
||||||
|
c = sqlite3.connect(f"file:{DB}?mode=ro", uri=True, timeout=120)
|
||||||
|
c.row_factory = sqlite3.Row
|
||||||
|
return c
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- индекс
|
||||||
|
|
||||||
|
def _ts(s):
|
||||||
|
return int(dt.datetime.fromisoformat(s).timestamp())
|
||||||
|
|
||||||
|
|
||||||
|
def _line(m):
|
||||||
|
text = (m.get("text") or "").strip()
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
who = m.get("from") or "?"
|
||||||
|
return f"{who}: {re.sub(r'\s+', ' ', text)}"
|
||||||
|
|
||||||
|
|
||||||
|
def _windows(msgs):
|
||||||
|
"""Сообщения (по возрастанию id) → куски разговора."""
|
||||||
|
cur, out = [], []
|
||||||
|
for m in msgs:
|
||||||
|
line = _line(m)
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
ts = _ts(m["date"])
|
||||||
|
if cur and (ts - cur[-1][1] > GAP or sum(len(x[2]) for x in cur) + len(line) > CHUNK_CHARS):
|
||||||
|
out.append(cur)
|
||||||
|
cur = []
|
||||||
|
cur.append((m["id"], ts, line))
|
||||||
|
if cur:
|
||||||
|
out.append(cur)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_index(a):
|
||||||
|
c = db(write=True)
|
||||||
|
chats = sorted(p for p in (OUT / "chats").glob("*") if (p / "_pretty.jsonl").exists())
|
||||||
|
total = 0
|
||||||
|
for folder in chats:
|
||||||
|
meta = json.loads((folder / "meta.json").read_text()) if (folder / "meta.json").exists() else {}
|
||||||
|
cid = int(meta.get("id") or folder.name.split("_")[0])
|
||||||
|
row = c.execute("SELECT last_id FROM chat WHERE id=?", (cid,)).fetchone()
|
||||||
|
last = row["last_id"] if row else 0
|
||||||
|
c.execute("INSERT INTO chat(id, title, username, kind, folder, last_id) VALUES (?,?,?,?,?,?) "
|
||||||
|
"ON CONFLICT(id) DO UPDATE SET title=excluded.title, username=excluded.username, kind=excluded.kind, "
|
||||||
|
"folder=excluded.folder", (cid, meta.get("name") or folder.name, meta.get("username"), meta.get("kind"),
|
||||||
|
folder.name, last))
|
||||||
|
# последний кусок мог быть недописан (разговор продолжился) — пересобираем его вместе с новыми сообщениями
|
||||||
|
tail = c.execute("SELECT id, first_id, ts1 FROM chunk WHERE chat_id=? ORDER BY last_id DESC LIMIT 1", (cid,)).fetchone()
|
||||||
|
msgs = []
|
||||||
|
with (folder / "_pretty.jsonl").open(encoding="utf-8") as f:
|
||||||
|
floor = tail["first_id"] - 1 if tail else last
|
||||||
|
for raw in f:
|
||||||
|
mid = re.match(r'\{"id": (\d+)', raw) # дёшево отсеять уже проиндексированное без разбора JSON
|
||||||
|
if not mid or int(mid.group(1)) <= floor:
|
||||||
|
continue
|
||||||
|
m = json.loads(raw)
|
||||||
|
if m.get("date"):
|
||||||
|
msgs.append(m)
|
||||||
|
msgs.sort(key=lambda m: m["id"])
|
||||||
|
new = [m for m in msgs if m["id"] > last]
|
||||||
|
if not new:
|
||||||
|
continue
|
||||||
|
if tail and _ts(new[0]["date"]) - tail["ts1"] <= GAP:
|
||||||
|
c.execute("DELETE FROM chunk WHERE id=?", (tail["id"],))
|
||||||
|
else:
|
||||||
|
msgs = new
|
||||||
|
wins = _windows(msgs)
|
||||||
|
title = meta.get("name") or folder.name
|
||||||
|
for i in range(0, len(wins), 256):
|
||||||
|
part = wins[i:i + 256]
|
||||||
|
texts = [f"Чат: {title}\n" + "\n".join(x[2] for x in w) for w in part]
|
||||||
|
vecs = embed(texts)
|
||||||
|
c.executemany("INSERT INTO chunk(chat_id, first_id, last_id, ts0, ts1, n, text, emb) VALUES (?,?,?,?,?,?,?,?)",
|
||||||
|
[(cid, w[0][0], w[-1][0], w[0][1], w[-1][1], len(w), "\n".join(x[2] for x in w), v.tobytes())
|
||||||
|
for w, v in zip(part, vecs)])
|
||||||
|
c.commit()
|
||||||
|
if not a.quiet:
|
||||||
|
print(f" {title}: {min(i + 256, len(wins))}/{len(wins)} кусков", flush=True)
|
||||||
|
c.execute("UPDATE chat SET last_id=? WHERE id=?", (max(m["id"] for m in new), cid))
|
||||||
|
c.commit()
|
||||||
|
total += len(new)
|
||||||
|
c.execute("INSERT OR REPLACE INTO meta VALUES ('indexed', ?)", (dt.datetime.now(TZ).isoformat(timespec="seconds"),))
|
||||||
|
c.commit()
|
||||||
|
print(f"проиндексировано новых сообщений: {total}")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- поиск
|
||||||
|
|
||||||
|
def link(chat, msg_id):
|
||||||
|
if chat["username"]:
|
||||||
|
return f"https://t.me/{chat['username']}/{msg_id}"
|
||||||
|
cid = str(chat["id"])
|
||||||
|
return f"https://t.me/c/{cid[4:] if cid.startswith('-100') else cid.lstrip('-')}/{msg_id}"
|
||||||
|
|
||||||
|
|
||||||
|
def fts_query(q):
|
||||||
|
words = [re.sub(r"[^\w]", "", w) for w in q.split()]
|
||||||
|
out = []
|
||||||
|
for w in words:
|
||||||
|
if len(w) < 3:
|
||||||
|
continue
|
||||||
|
stem = re.sub(r"(ами|ями|ого|его|ому|ему|ой|ей|ий|ый|ая|яя|ое|ее|ые|ие|ам|ям|ах|ях|ом|ем|ов|ев|а|я|ы|и|у|ю|е|о)$", "", w) \
|
||||||
|
if re.fullmatch(r"[а-яё]{5,}", w.lower()) else w
|
||||||
|
out.append(f'"{stem}"*')
|
||||||
|
return " OR ".join(out)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_since(s):
|
||||||
|
if not s:
|
||||||
|
return None
|
||||||
|
m = re.fullmatch(r"(\d+)\s*(d|д|w|н|m|м)", s.strip().lower())
|
||||||
|
if m:
|
||||||
|
days = int(m.group(1)) * {"d": 1, "д": 1, "w": 7, "н": 7, "m": 30, "м": 30}[m.group(2)]
|
||||||
|
return int(time.time()) - days * 86400
|
||||||
|
return int(dt.datetime.combine(dt.date.fromisoformat(s), dt.time(0), TZ).timestamp())
|
||||||
|
|
||||||
|
|
||||||
|
def search(q, chat=None, since=None, n=12, min_sim=None):
|
||||||
|
"""Гибрид: BM25 (FTS5) + косинус эмбеддингов, слияние по рангам (RRF). → [(row, sim)]"""
|
||||||
|
import numpy as np
|
||||||
|
c = db()
|
||||||
|
where, args = ["1=1"], []
|
||||||
|
if chat:
|
||||||
|
where.append("c.chat_id IN (SELECT id FROM chat WHERE title LIKE ? OR username LIKE ?)")
|
||||||
|
args += [f"%{chat}%", f"%{chat}%"]
|
||||||
|
if since:
|
||||||
|
where.append("c.ts1 >= ?")
|
||||||
|
args.append(since)
|
||||||
|
w = " AND ".join(where)
|
||||||
|
ranks, sims, by_words = {}, {}, set()
|
||||||
|
fq = fts_query(q)
|
||||||
|
if fq:
|
||||||
|
for r, row in enumerate(c.execute(f"SELECT c.id FROM chunk_fts f JOIN chunk c ON c.id=f.rowid WHERE chunk_fts MATCH ? "
|
||||||
|
f"AND {w} ORDER BY bm25(chunk_fts) LIMIT 60", [fq] + args)):
|
||||||
|
ranks[row["id"]] = ranks.get(row["id"], 0) + 1 / (60 + r)
|
||||||
|
by_words.add(row["id"])
|
||||||
|
qv = embed([q])[0]
|
||||||
|
ids, mats = [], []
|
||||||
|
for row in c.execute(f"SELECT c.id, c.emb FROM chunk c WHERE {w}", args): # 384 float32 на кусок — в памяти ок
|
||||||
|
ids.append(row["id"])
|
||||||
|
mats.append(row["emb"])
|
||||||
|
if ids:
|
||||||
|
m = np.frombuffer(b"".join(mats), dtype=np.float32).reshape(len(ids), -1)
|
||||||
|
s = m @ qv
|
||||||
|
for r, i in enumerate(np.argsort(-s)[:60]):
|
||||||
|
sims[ids[i]] = float(s[i])
|
||||||
|
ranks[ids[i]] = ranks.get(ids[i], 0) + 1 / (60 + r)
|
||||||
|
top = sorted(ranks, key=ranks.get, reverse=True)
|
||||||
|
if min_sim is not None: # для сводки: найденное словами — всегда, по смыслу — только достаточно близкое
|
||||||
|
top = [i for i in top if i in by_words or sims.get(i, 0) >= min_sim]
|
||||||
|
top = top[:n]
|
||||||
|
if not top:
|
||||||
|
return []
|
||||||
|
rows = {r["id"]: r for r in c.execute(
|
||||||
|
f"SELECT c.*, ch.title, ch.username, ch.id AS cid FROM chunk c JOIN chat ch ON ch.id=c.chat_id "
|
||||||
|
f"WHERE c.id IN ({','.join('?' * len(top))})", top)}
|
||||||
|
return [(rows[i], sims.get(i)) for i in top if i in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def fmt_hit(r, sim=None, cap=900):
|
||||||
|
chat = {"id": r["cid"], "username": r["username"]}
|
||||||
|
t0 = dt.datetime.fromtimestamp(r["ts0"], TZ)
|
||||||
|
text = r["text"] if len(r["text"]) <= cap else r["text"][:cap] + " …"
|
||||||
|
return (f"── {r['title']} · {t0:%d.%m.%Y %H:%M} · {link(chat, r['first_id'])}"
|
||||||
|
+ (f" · близость {sim:.2f}" if sim is not None else "") + f"\n{text}\n")
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_search(a):
|
||||||
|
hits = search(" ".join(a.query), a.chat, parse_since(a.since), a.n)
|
||||||
|
if not hits:
|
||||||
|
print("Ничего не нашлось.")
|
||||||
|
for r, sim in hits:
|
||||||
|
print(fmt_hit(r, sim))
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_stats(a):
|
||||||
|
c = db()
|
||||||
|
chats = c.execute("SELECT count(*) FROM chat").fetchone()[0]
|
||||||
|
n, msgs, last = c.execute("SELECT count(*), coalesce(sum(n),0), max(ts1) FROM chunk").fetchone()
|
||||||
|
upd = c.execute("SELECT v FROM meta WHERE k='indexed'").fetchone()
|
||||||
|
print(f"чатов {chats}, кусков {n}, сообщений с текстом {msgs}; последнее сообщение "
|
||||||
|
f"{dt.datetime.fromtimestamp(last, TZ):%d.%m.%Y %H:%M}" if last else "пусто",
|
||||||
|
f"; индекс обновлён {upd[0] if upd else '—'}")
|
||||||
|
for r in c.execute("SELECT ch.title, count(k.id) n, max(k.ts1) last FROM chat ch LEFT JOIN chunk k ON k.chat_id=ch.id "
|
||||||
|
"GROUP BY ch.id ORDER BY n DESC LIMIT 30"):
|
||||||
|
print(f" {r['title'][:50]:<50} кусков {r['n']:>6}"
|
||||||
|
+ (f", последнее {dt.datetime.fromtimestamp(r['last'], TZ):%d.%m %H:%M}" if r["last"] else ""))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- темы и сводка
|
||||||
|
|
||||||
|
def topics():
|
||||||
|
f = REPORT / "topics.json"
|
||||||
|
return json.loads(f.read_text()) if f.exists() else []
|
||||||
|
|
||||||
|
|
||||||
|
def save_topics(t):
|
||||||
|
REPORT.mkdir(parents=True, exist_ok=True)
|
||||||
|
(REPORT / "topics.json").write_text(json.dumps(t, ensure_ascii=False, indent=1))
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_topic(a):
|
||||||
|
t = topics()
|
||||||
|
if a.op == "add":
|
||||||
|
t.append({"title": a.title.strip(), "query": (a.query or a.title).strip(), "added": dt.date.today().isoformat()})
|
||||||
|
save_topics(t)
|
||||||
|
print(f"Тема {len(t)}: {a.title}")
|
||||||
|
elif a.op == "del":
|
||||||
|
i = int(a.num) - 1
|
||||||
|
if not 0 <= i < len(t):
|
||||||
|
raise SystemExit("нет такой темы")
|
||||||
|
print("Удалена:", t.pop(i)["title"])
|
||||||
|
save_topics(t)
|
||||||
|
else:
|
||||||
|
for i, x in enumerate(t, 1):
|
||||||
|
print(f"{i}. {x['title']}" + (f" (ищу: {x['query']})" if x["query"] != x["title"] else ""))
|
||||||
|
if not t:
|
||||||
|
print("Тем нет. Добавить: tg topic add \"тема\"")
|
||||||
|
|
||||||
|
|
||||||
|
def codex(prompt):
|
||||||
|
with tempfile.TemporaryDirectory() as d:
|
||||||
|
out = Path(d) / "out.txt"
|
||||||
|
subprocess.run(["codex", "exec", "--skip-git-repo-check", "--sandbox", "read-only", "--ephemeral",
|
||||||
|
"--color", "never", "-o", str(out), "-"], input=prompt, text=True, cwd=d,
|
||||||
|
capture_output=True, timeout=900, check=True)
|
||||||
|
return out.read_text().strip()
|
||||||
|
|
||||||
|
|
||||||
|
REPORT_PROMPT = """Ты готовишь владельцу утреннюю сводку по Telegram-группам за последние {hours} ч по его темам.
|
||||||
|
Ниже по каждой теме — найденные поиском куски переписки (группа, время, ссылка, текст). Поиск грубый: часть кусков
|
||||||
|
к теме не относится — их пропусти.
|
||||||
|
|
||||||
|
Для каждой темы, где есть действительно относящееся: 2–6 пунктов по сути (что предлагают/советуют/спрашивают,
|
||||||
|
конкретика — места, цены, контакты, даты), у каждого пункта — ссылка на сообщение из данных. Ничего не выдумывай
|
||||||
|
и не добавляй от себя. Тему без относящегося — одной строкой «ничего нового». Без вступлений и выводов.
|
||||||
|
Формат — простой текст для Telegram без Markdown: заголовок темы строкой «▸ Тема», пункты — «• …».
|
||||||
|
Пиши по-русски.
|
||||||
|
|
||||||
|
{data}"""
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_report(a):
|
||||||
|
t = topics()
|
||||||
|
if not t:
|
||||||
|
print("Тем нет — сводку не делаю.")
|
||||||
|
return
|
||||||
|
if not DB.exists():
|
||||||
|
print("Индекса ещё нет (выгрузка не запускалась) — сводку не делаю.")
|
||||||
|
return
|
||||||
|
since = int(time.time()) - a.hours * 3600
|
||||||
|
blocks = []
|
||||||
|
for x in t:
|
||||||
|
hits = search(x["query"], since=since, n=15, min_sim=0.4)
|
||||||
|
body = "\n".join(fmt_hit(r, cap=700) for r, _ in hits) or "(ничего не найдено)"
|
||||||
|
blocks.append(f"### Тема: {x['title']}\n{body}")
|
||||||
|
text = codex(REPORT_PROMPT.format(hours=a.hours, data="\n\n".join(blocks)))
|
||||||
|
text = f"🗞 Telegram за {a.hours} ч — {dt.datetime.now(TZ):%d.%m}\n\n{text}"
|
||||||
|
if a.dry:
|
||||||
|
print(text)
|
||||||
|
return
|
||||||
|
OUTBOX.mkdir(parents=True, exist_ok=True)
|
||||||
|
parts, cur = [], ""
|
||||||
|
for para in text.split("\n"):
|
||||||
|
if len(cur) + len(para) + 1 > TG_MAX and cur:
|
||||||
|
parts.append(cur)
|
||||||
|
cur = ""
|
||||||
|
cur += para[:TG_MAX] + "\n"
|
||||||
|
if cur.strip():
|
||||||
|
parts.append(cur)
|
||||||
|
old = os.umask(0o007)
|
||||||
|
try:
|
||||||
|
stamp = dt.datetime.now().strftime("%Y%m%d%H%M%S%f")
|
||||||
|
for i, p in enumerate(parts):
|
||||||
|
(OUTBOX / f"{stamp}-tg{i:02d}.txt").write_text(p.strip())
|
||||||
|
finally:
|
||||||
|
os.umask(old)
|
||||||
|
REPORT.mkdir(parents=True, exist_ok=True)
|
||||||
|
(REPORT / "last-report.txt").write_text(text)
|
||||||
|
print(f"сводка поставлена в очередь: {len(parts)} сообщ.")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
p = argparse.ArgumentParser(prog="tg", description=__doc__.split("\n")[0])
|
||||||
|
sub = p.add_subparsers(dest="cmd", required=True)
|
||||||
|
s = sub.add_parser("search"); s.add_argument("query", nargs="+"); s.add_argument("--chat"); s.add_argument("--since")
|
||||||
|
s.add_argument("-n", type=int, default=12)
|
||||||
|
s = sub.add_parser("topic"); ts = s.add_subparsers(dest="op", required=True)
|
||||||
|
r = ts.add_parser("add"); r.add_argument("title"); r.add_argument("--query")
|
||||||
|
r = ts.add_parser("del"); r.add_argument("num")
|
||||||
|
ts.add_parser("list")
|
||||||
|
s = sub.add_parser("report"); s.add_argument("--hours", type=int, default=24); s.add_argument("--dry", action="store_true")
|
||||||
|
sub.add_parser("stats")
|
||||||
|
s = sub.add_parser("index"); s.add_argument("--quiet", action="store_true")
|
||||||
|
a = p.parse_args()
|
||||||
|
globals()["cmd_" + a.cmd](a)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Generated
+86
@@ -0,0 +1,86 @@
|
|||||||
|
version = 1
|
||||||
|
revision = 3
|
||||||
|
requires-python = ">=3.13"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cryptg"
|
||||||
|
version = "0.6.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/9a/cc/3cc53619c07ab1def2f0ce7a958c10528c947e973821234714cfc61d7dd7/cryptg-0.6.0.tar.gz", hash = "sha256:f8fd59fea56398ab812f218c044b42fbbaeee8a9ae32771d3108a3cf210781aa", size = 15019, upload-time = "2026-04-12T13:31:09.978Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c8/77/b56510a48a5c8f3cd38a0a2f3b93a6eee9e1e27ca3b1b8a3ad4a323f8311/cryptg-0.6.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0ad5ccdd23d95f857c90e4109a7a8bd21cd998b342d8447d69881125f72b337e", size = 208770, upload-time = "2026-04-12T18:33:51.402Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/80/92/2780c8f178396c64e1a8bf350d68e66a08d801cd0ba355e81f8d10e58bee/cryptg-0.6.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:2d5a99c8c3897930974f47bbfe75ab3304606a1d98f66b0d35d9dafdd98e0512", size = 238481, upload-time = "2026-04-12T18:35:15.194Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/aa/db/84a3375d757c55f8c0e6d471828560a0f5bd6e8b2ecdf94cee44c1657c3d/cryptg-0.6.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:28497a18178b09f39b3d5faeaa34235ac1762b74017fbeac7bc34724c8efe0a0", size = 250130, upload-time = "2026-04-12T18:35:34.83Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/52/1a/28f81903ac4c0a1db0f17b1774e67bcc64733d89188f21c10d3b19aef396/cryptg-0.6.0-cp313-cp313-win32.whl", hash = "sha256:c1bc4edee45eda7dd5963420d578bbf86f0a7e2cbbf0d25d5a812b4293a223f0", size = 110091, upload-time = "2026-04-12T18:36:01.976Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/24/c3/742794894911bac8d83971b4a9ce742a0ca3b64d13ee2a64d2bb3cf5f01e/cryptg-0.6.0-cp313-cp313-win_amd64.whl", hash = "sha256:2b11d6ac297a63ba413cb79c97030cfb93763f041fd85755a488e0db13699336", size = 109113, upload-time = "2026-04-12T18:36:03.613Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a9/88/51f40b7991d77fab8d307fbe2b7e28dbef6d499141f953b0ade73faa2ab7/cryptg-0.6.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e726a6a40967d91ad1eaa0f6245cc79035b528c059a22de34aca18bfb61efd2e", size = 208094, upload-time = "2026-04-12T18:33:52.905Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/17/71/16f4b747d9be7c363be16717d4c2094d710f09acce15efea39e21aaff9a7/cryptg-0.6.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:3ccaa95f9ed6e6457b80ca003c99ee035c072dc421636b2482d069fc575ab5ba", size = 236844, upload-time = "2026-04-12T18:35:17.103Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/25/ae/8af9ebfa5aaca1da3d2ab8520b886b41194be1019e9cb414c5c4c492afaa/cryptg-0.6.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:c1931178451aa59da98294973bf6916115572a412514960d8fd8afb7919fb7fd", size = 250346, upload-time = "2026-04-12T18:35:36.593Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d8/d9/0214b6bc2b153abf91359de1a6035e59832e7377152de8749b378e6eebb5/cryptg-0.6.0-cp313-cp313t-win32.whl", hash = "sha256:0c312d2c0cc592cbf7bb08c1374f3ab27bcb01f46739b3ba23a4108f4f48661d", size = 109723, upload-time = "2026-04-12T18:35:58.77Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b0/89/133592fdb0859c639b5e62f5dfa24de4474b2e4e223668210e063592ddc8/cryptg-0.6.0-cp313-cp313t-win_amd64.whl", hash = "sha256:8748fe0ba8cce4268b2f3570f9fc8cd5e7d1730598174e271f525a93d5f64c90", size = 108149, upload-time = "2026-04-12T18:36:00.377Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/68/ca/740ba5201f47d50cd24809719ddff38b8a5d950f9921e426a0d2990de084/cryptg-0.6.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7eb4e335ac3df00333c202cf98e65edbecd8983c9a639c11d93f1f369ae64e05", size = 208586, upload-time = "2026-04-12T18:33:54.23Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ab/d0/cfc3664d4b8c57f2058278472de7052719d2e37aece5591512d07e14777e/cryptg-0.6.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:28f6ca127a907d00ad075252e8d970bbac55e7f3df0f66a277f52b986fab0a57", size = 238396, upload-time = "2026-04-12T18:35:19.201Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/16/59/abc03cb099cec7b5e28866a8401040b40e99c719f3bef49fa7a0b302e09a/cryptg-0.6.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:4127534527ff741026b981359083da660010ddd556eb5e0f22523b9ce0fe80a2", size = 250040, upload-time = "2026-04-12T18:35:38.802Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8a/6e/707de2c52842f093ab05811d002214e284c50f18057160352d7baa4ef1fa/cryptg-0.6.0-cp314-cp314-win32.whl", hash = "sha256:9c823319f6ff00c3c04be00cad78bd6555ccfc832fffc6b709eb983bbe0182b0", size = 112774, upload-time = "2026-04-12T18:36:08.654Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d2/49/6d8c6313789c3649f089a5f277369013072a5f8d96ba93d8c83def9c4a75/cryptg-0.6.0-cp314-cp314-win_amd64.whl", hash = "sha256:0776b0256cd79ff202dd4c187872eb309bbee7ea63b9be722f9063b232b42ecd", size = 112208, upload-time = "2026-04-12T18:36:10.289Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/de/31/3df7c7bfa022b8e49fba5bd511226eb8ab02b335832441dc9d5c07a154e7/cryptg-0.6.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:877e872b0967f03d008e2cb16a1aa44c8011b2f200bdf14771e6f783228e8bce", size = 207943, upload-time = "2026-04-12T18:33:55.483Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/06/81/dea9beefd7317c353484a5736ce3de69ef9c1e84a93ea46cbe47f04a7f18/cryptg-0.6.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:b8ba3ed799086af279c1efb7bfa72b226dc9427f85034ab0b05ff23bccb97b45", size = 236856, upload-time = "2026-04-12T18:35:21.006Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e9/f1/fcc52170486f35698229b573c0b6bc844f0504745f43fc47fde0cb61888e/cryptg-0.6.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:ae96b26dfb53968fe4986f70222f2419c82651936f2ef3b2c2dc47bc74e77d96", size = 250297, upload-time = "2026-04-12T18:35:40.577Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c3/d5/5041385f3a55d6eaa8f86c79e9fbb799d09680977619d506e35c09a9a3f9/cryptg-0.6.0-cp314-cp314t-win32.whl", hash = "sha256:e4988fd1657aa86d91bc98d8262b78b141dec8ef51a843f1ef78d69e8a81fdf0", size = 111989, upload-time = "2026-04-12T18:36:05.193Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/88/08/86db1d558e836c75724ef893c024d3f087886d1f8b173f2f64164505e256/cryptg-0.6.0-cp314-cp314t-win_amd64.whl", hash = "sha256:43f195810c642c6f6c552d291a216a5b5cb36b06aabe2832873db9dde3e65bb1", size = 111602, upload-time = "2026-04-12T18:36:06.81Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pyaes"
|
||||||
|
version = "1.6.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/44/66/2c17bae31c906613795711fc78045c285048168919ace2220daa372c7d72/pyaes-1.6.1.tar.gz", hash = "sha256:02c1b1405c38d3c370b085fb952dd8bea3fadcee6411ad99f312cc129c536d8f", size = 28536, upload-time = "2017-09-20T21:17:54.23Z" }
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pyasn1"
|
||||||
|
version = "0.6.4"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/a4/9a/23310166d960def5897e91fe20e5b724601b02a22e84ba1f94232c0b7f67/pyasn1-0.6.4.tar.gz", hash = "sha256:9c447d8431c947fe4c8febc4ed9e760bc29011a5b01e5c74b67025bd9fb8ce81", size = 151262, upload-time = "2026-07-09T01:12:33.988Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9a/3b/6163796d69c3977d1e4287bea4a6979161cbbdd170ebb430511e8e1999ce/pyasn1-0.6.4-py3-none-any.whl", hash = "sha256:deda9277cfd454080ec40b207fb6df82206a3a2688735233cdcd8d3d565f088b", size = 84410, upload-time = "2026-07-09T01:12:32.92Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rsa"
|
||||||
|
version = "4.9.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "pyasn1" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/da/8a/22b7beea3ee0d44b1916c0c1cb0ee3af23b700b6da9f04991899d0c555d4/rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75", size = 29034, upload-time = "2025-04-16T09:51:18.218Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696, upload-time = "2025-04-16T09:51:17.142Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "telethon"
|
||||||
|
version = "1.44.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "pyaes" },
|
||||||
|
{ name = "rsa" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/49/e2/d4137e598db0ba50e4cf9bf32f2e26d016b2c4bb0e9763e3d5a07007d780/telethon-1.44.0.tar.gz", hash = "sha256:7de1473e9e412e20bd57b102d8f50d48372712462be9ff6c003bb81c50ad7e98", size = 701463, upload-time = "2026-06-15T15:47:39.44Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/82/fd/4ad621d7a4b8655dfc964ee1c1267407496ec6fd10c91901a64ea29c16c8/telethon-1.44.0-py3-none-any.whl", hash = "sha256:52fc49efb67a4916c2aedcb295ad286f4afa2aba9bf15d83ed2acdc64af0c718", size = 789786, upload-time = "2026-06-15T15:47:41.534Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tg-export"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = { virtual = "." }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "cryptg" },
|
||||||
|
{ name = "telethon" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.metadata]
|
||||||
|
requires-dist = [
|
||||||
|
{ name = "cryptg", specifier = ">=0.6.0" },
|
||||||
|
{ name = "telethon", specifier = ">=1.44.0" },
|
||||||
|
]
|
||||||
Executable
+3
@@ -0,0 +1,3 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# tg — поиск по выгрузке Telegram и сводка по темам (infra stacks/rpi-tg-export, app/tgrag.py). Запускает astra.
|
||||||
|
exec /opt/tg-export/venv/bin/python /opt/tg-export/tgrag.py "$@"
|
||||||
Executable
+9
@@ -0,0 +1,9 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# tg-export КОМАНДА [опции export.py] — выгрузка второго Telegram-аккаунта владельца (infra stacks/rpi-tg-export).
|
||||||
|
# Запускать от tgexport: sudo -u tgexport tg-export list | login | run …
|
||||||
|
# Ключи приложения — /etc/tg-export/env (root:tgexport 640), сессия — /var/lib/tg-export/tg.session (tgexport 600),
|
||||||
|
# выгрузка — /mnt/backup/tg (группа astra, только чтение).
|
||||||
|
set -eu
|
||||||
|
set -a; . /etc/tg-export/env; set +a
|
||||||
|
exec /opt/tg-export/venv/bin/python /opt/tg-export/export.py "$@" \
|
||||||
|
--session /var/lib/tg-export/tg.session --out /mnt/backup/tg
|
||||||
Executable
+17
@@ -0,0 +1,17 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Установка/обновление выгрузки Telegram на rpi: sudo sh install.sh (из копии каталога stacks/rpi-tg-export)
|
||||||
|
# Ключи (/etc/tg-export/env) и вход (sudo -u tgexport tg-export login) — отдельно, см. README.
|
||||||
|
set -eu
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
id tgexport >/dev/null 2>&1 || useradd --system --home-dir /var/lib/tg-export --shell /usr/sbin/nologin tgexport
|
||||||
|
install -d -m 755 /opt/tg-export
|
||||||
|
install -m 644 app/export.py app/fetch_public.py app/channel_comments.py app/pyproject.toml /opt/tg-export/
|
||||||
|
[ -x /opt/tg-export/venv/bin/python ] || python3 -m venv /opt/tg-export/venv
|
||||||
|
/opt/tg-export/venv/bin/pip install -q --disable-pip-version-check 'telethon>=1.44.0' 'cryptg>=0.6.0'
|
||||||
|
install -m 755 bin/tg-export /usr/local/bin/tg-export
|
||||||
|
install -d -o root -g tgexport -m 750 /etc/tg-export
|
||||||
|
install -d -o tgexport -g tgexport -m 700 /var/lib/tg-export
|
||||||
|
install -d -o tgexport -g astra -m 2750 /mnt/backup/tg
|
||||||
|
install -m 644 systemd/tg-export.service systemd/tg-export.timer /etc/systemd/system/
|
||||||
|
systemctl daemon-reload
|
||||||
|
echo "ok: код в /opt/tg-export; таймер включается после входа: systemctl enable --now tg-export.timer"
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Telegram: докачка истории второго аккаунта владельца (только текст) и индекс для поиска
|
||||||
|
Wants=network-online.target
|
||||||
|
After=network-online.target
|
||||||
|
RequiresMountsFor=/mnt/backup
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
User=tgexport
|
||||||
|
Group=tgexport
|
||||||
|
# файлы выгрузки 640, каталог setgid astra — Astra читает, остальные пользователи малины нет
|
||||||
|
UMask=0027
|
||||||
|
Nice=10
|
||||||
|
# первый прогон долгий: Telegram троттлит чтение истории (FloodWait), скрипт ждёт сам
|
||||||
|
TimeoutStartSec=infinity
|
||||||
|
# только выбранные владельцем чаты (2026-10-18); не вступивший чат --only просто не находит — подтянется после вступления.
|
||||||
|
# -1001315261949 — Cyprus Car – Chat (без username). История — с 2024-10-18 (последние 2 года, владелец 2026-10-18).
|
||||||
|
ExecStart=/usr/local/bin/tg-export run --no-media --archived --since 2024-10-18 --only @CupRusHelpers @cylaw @badcyprus @cyprusdiy @cymedic -1001315261949
|
||||||
|
# индекс для поиска и сводки: новые сообщения → куски + эмбеддинги (/mnt/backup/tg/index.db)
|
||||||
|
ExecStartPost=/opt/tg-export/venv/bin/python /opt/tg-export/tgrag.py index --quiet
|
||||||
|
ExecStopPost=+/usr/local/bin/backup-metrics backup_tg_export
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Telegram: докачка истории второго аккаунта каждый час (свежий поиск и утренняя сводка)
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnCalendar=hourly
|
||||||
|
RandomizedDelaySec=5m
|
||||||
|
Persistent=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Telegram: утренняя сводка по темам владельца (Codex) → Telegram
|
||||||
|
After=network-online.target tg-export.service
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
User=astra
|
||||||
|
Group=astra
|
||||||
|
ExecStart=/usr/local/bin/tg report
|
||||||
|
TimeoutStartSec=30min
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Telegram: сводка по темам каждый день в 08:00
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnCalendar=*-*-* 08:00 Asia/Nicosia
|
||||||
|
Persistent=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# www.ruzzy.dev/astra — страницы для Google OAuth (Branding)
|
||||||
|
|
||||||
|
Домашняя страница и privacy policy OAuth-приложения «Astra» (Google Cloud, проект `astra`,
|
||||||
|
личный аккаунт владельца) — см. `servers/rpi.md`, раздел «Google». Отдаёт vhost `www.ruzzy.dev`
|
||||||
|
на fr1 (корень `/var/www/share.ruzzy.dev`, `try_files $uri =404` — ссылки только с `.html`).
|
||||||
|
|
||||||
|
Установка на fr1: скопировать каталог в `~/www-ruzzy` и `sudo sh ~/www-ruzzy/install.sh`.
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>Astra</title>
|
||||||
|
<style>body{font:16px/1.5 system-ui,sans-serif;max-width:640px;margin:40px auto;padding:0 16px;color:#222}</style>
|
||||||
|
</head><body>
|
||||||
|
<h1>Astra</h1>
|
||||||
|
<p>Astra is a private home assistant run by Ruslan Gilfanov for personal and family use only.
|
||||||
|
It is not offered to the public.</p>
|
||||||
|
<p>It uses the Google Calendar API to show the owner's agenda and to create or change events in the owner's
|
||||||
|
own Google Calendar on request.</p>
|
||||||
|
<p><a href="privacy.html">Privacy policy</a> · Contact: ruslan.gilfanov@gmail.com</p>
|
||||||
|
</body></html>
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>Astra — Privacy policy</title>
|
||||||
|
<style>body{font:16px/1.5 system-ui,sans-serif;max-width:640px;margin:40px auto;padding:0 16px;color:#222}</style>
|
||||||
|
</head><body>
|
||||||
|
<h1>Astra — Privacy policy</h1>
|
||||||
|
<p>Last updated: 2026-10-12.</p>
|
||||||
|
<p>Astra is a private assistant used only by its owner (Ruslan Gilfanov) and his family. It is not a public service.</p>
|
||||||
|
<h2>Data accessed</h2>
|
||||||
|
<p>With the owner's consent Astra accesses the owner's Google Calendar (events and calendars visible to the owner's
|
||||||
|
Google account) to show the agenda, find free time and create, change or delete events at the owner's request.</p>
|
||||||
|
<h2>Storage and sharing</h2>
|
||||||
|
<p>The OAuth token and calendar data are processed on the owner's own home server only. Data is not sold,
|
||||||
|
shared with third parties or used for advertising. Calendar data is not used to train AI models.</p>
|
||||||
|
<p>The use of information received from Google APIs adheres to the
|
||||||
|
<a href="https://developers.google.com/terms/api-services-user-data-policy">Google API Services User Data Policy</a>,
|
||||||
|
including the Limited Use requirements.</p>
|
||||||
|
<h2>Revoking access</h2>
|
||||||
|
<p>Access can be revoked at any time at <a href="https://myaccount.google.com/permissions">myaccount.google.com/permissions</a>.</p>
|
||||||
|
<p>Contact: ruslan.gilfanov@gmail.com</p>
|
||||||
|
</body></html>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Страницы Astra для Google OAuth Branding (homepage + privacy policy) → https://www.ruzzy.dev/astra/
|
||||||
|
# Запуск на fr1: sudo sh ~/www-ruzzy/install.sh
|
||||||
|
set -e
|
||||||
|
src=$(dirname "$0")/astra
|
||||||
|
install -d -o www-data -g www-data -m 755 /var/www/share.ruzzy.dev/astra
|
||||||
|
install -o www-data -g www-data -m 644 "$src/index.html" "$src/privacy.html" /var/www/share.ruzzy.dev/astra/
|
||||||
|
# /astra/ → index.html (vhost: try_files $uri =404, без index-каталога)
|
||||||
|
ls -l /var/www/share.ruzzy.dev/astra
|
||||||
Reference in New Issue
Block a user