Compare commits
132
Commits
705a7dce99
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce23ac2824 | ||
|
|
5e6df382c8 | ||
|
|
69ad9cf123 | ||
|
|
1a525e9073 | ||
|
|
15a9e1d2de | ||
|
|
dfe0fad728 | ||
|
|
3bdb75d465 | ||
|
|
48c129615f | ||
|
|
e327b53496 | ||
|
|
4da6260433 | ||
|
|
f7adfb15c3 | ||
|
|
a09106648b | ||
|
|
d13a05510c | ||
|
|
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/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. Скрипты для работы с чатами — выжимки и слежение за интересующей информацией
|
||||
- [x] 8. Собрать промежуточные точки доступа (mesh D-Link M15 ×2, E15) и настроить скиллы для других устройств — ✅ готово (владелец, 2026-10-18)
|
||||
- [ ] 12. Опознать `.46` (`ea:08:6d:8c:59:56`, iOS без имени) и подтвердить, что `.13` — iPhone жены
|
||||
- [x] 13. Подключить календарь для Полины — ✅ готово (владелец, 2026-10-18)
|
||||
- [x] 14. Подключить к почте Astra (индекс, классы, метки, ожидаемые письма) остальные ящики владельца —
|
||||
**ruzzyrullezz@gmail.com** подключён 2026-10-16 (см. `servers/rpi.md`, «Второй ящик»); другие ящики не нужны (владелец 2026-10-17).
|
||||
Для Gmail — тот же OAuth-клиент проекта `astra`, второй токен; ⚠️ квота ~100 писем/мин на пользователя — первичная индексация долгая.
|
||||
- [x] 15. **MCP ко всему этому** — подключать Claude (claude.ai web/Desktop/телефон, Claude Code, Astra) к книгам и прочему. — ✅ готово (владелец, 2026-10-18)
|
||||
Читалка 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`) — убедиться, что прошла.
|
||||
+42
-11
@@ -15,10 +15,11 @@
|
||||
- ⚠️ **Группа `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).
|
||||
- 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`.
|
||||
|
||||
## 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
|
||||
@@ -29,7 +30,10 @@ ChallengeResponseAuthentication no
|
||||
MaxAuthTries 3
|
||||
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, сейчас просто статический файл).
|
||||
@@ -44,7 +48,7 @@ AllowUsers rus tgclaude
|
||||
## DNS
|
||||
- Зона `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).
|
||||
- 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 — снести руками.
|
||||
- PTR (reverse DNS): дефолтный `vmi3387709.contaboserver.net` (не менялся).
|
||||
|
||||
@@ -60,13 +64,16 @@ AllowUsers rus tgclaude
|
||||
```
|
||||
[DEFAULT]
|
||||
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
|
||||
|
||||
[sshd] enabled=true
|
||||
[recidive] enabled=true bantime=1w findtime=1d maxretry=5
|
||||
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.
|
||||
@@ -100,9 +107,10 @@ backend=systemd banaction=ufw
|
||||
## nginx + TLS
|
||||
- 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'ы `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 `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`) и получает его сертификат.
|
||||
- Сертификаты LE: `mon`, `git`, `share`, `www`. `cam.ruzzy.dev` удалён 2026-09-08 вместе с ipcam.
|
||||
- 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`) и получает его сертификат. Так отвечают и `https://ruzzy.dev`, и `https://fr1.ruzzy.dev` — своих vhost'ов и сертификатов у них нет, браузер видит несовпадение имени (проверено 2026-10-11).
|
||||
- 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/`)
|
||||
- Стек: **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) удалены.
|
||||
- Как сделать разовую операцию через 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
|
||||
- Зеркало 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).
|
||||
@@ -150,8 +173,14 @@ backend=systemd banaction=ufw
|
||||
- ⚠️ Но том **раздулся заново: 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`). Том — чувствительный артефакт, обращаться как с секретом.
|
||||
- **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-инструменты не работают, на бот не влияет).
|
||||
- **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`.
|
||||
- **Продолжение в комментарии — поиск копии поста** (исправлено 2026-10-18 вечером, бэкап `/root/publisher/publish.mjs.bak-20261018-comments`): слот 21:00 ушёл ответами в канал — перебор пробами (до 20 ответов-проб в группу на попытку) упёрся в лимит Bot API ~20 сообщений/мин в группу (`429 retry after 22`). Теперь копия берётся из `getChat(группа).pinned_message`: Telegram сам закрепляет каждую автоматическую копию поста в группе обсуждений (проверено: закреплена копия последнего поста) — один запрос, без проб; перебор — запасной (6 сообщений от пробы, либо до 40 вниз от закреплённой копии более позднего поста — для `--fix-comments`), с паузой 1,5 с; `tg()` на 429 ждёт `retry_after` и повторяет. Слот 21:00 перенесён `--fix-comments` (пост 22, глава 5). ⚠️ Код публикатора — только в томе контейнера, в git его нет.
|
||||
- **Комментарии 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-уведомления
|
||||
- Бот @ultimate_log_bot. Конфиг `/etc/tg-notify.conf` (chmod 600, токен+chat_id — секрет на сервере).
|
||||
@@ -198,7 +227,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), лог пишется.
|
||||
- Образ-снимок `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 на выселение с сервера, если понадобится место.
|
||||
- `~/downloads` — пустой. `~/MyVault` (186 МБ) — см. claude-channels.
|
||||
- `~/downloads` — пустой. `~/MyVault` удалён 2026-10-11 — см. claude-channels.
|
||||
|
||||
## Выведено с 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 и настройка аплоада на самой камере.
|
||||
@@ -214,7 +243,7 @@ backend=systemd banaction=ufw
|
||||
Полная инвентаризация на 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 (см. раздел «Сеть»).
|
||||
- `certbot.timer` + `/etc/cron.d/certbot` — продление сертификатов (крон-вариант выходит no-op на systemd).
|
||||
- `sysstat-collect.timer` (`*:00/10`) и `sysstat-summary.timer` (00:07) — сбор и суточный отчёт sar.
|
||||
@@ -253,7 +282,7 @@ backend=systemd banaction=ufw
|
||||
- `~tgclaude/.openclaw-auth-profile-secrets` — права **777** на каталоге для секретов (сейчас пуст), стоит `chmod 700`.
|
||||
- Не решено, нужны ли на 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`.
|
||||
- ⏳ Дроп-ин `/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, так что могут врать.
|
||||
|
||||
## Как это описание поддерживается
|
||||
@@ -289,3 +318,5 @@ backend=systemd banaction=ufw
|
||||
|
||||
## Установленные пакеты (сверх базы)
|
||||
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 нет. Не перепушивать старые результаты ради исчезновения алерта.
|
||||
|
||||
+154
-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).
|
||||
# push_time_seconds — когда группа последний раз обновлялась.
|
||||
# SMART отправляется каждые 15 минут. Итог backup_* — только после прогона;
|
||||
# свежесть бэкапов проверяется BackupStale/BackupNeverReported по расписанию.
|
||||
- name: rpi
|
||||
rules:
|
||||
- alert: PushMetricsStale
|
||||
expr: time() - push_time_seconds{job=~"smart|backup_.*"} > 3 * 3600
|
||||
expr: time() - push_time_seconds{job="smart"} > 3 * 3600
|
||||
for: 10m
|
||||
labels: {severity: warning}
|
||||
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_servers"} > 26 * 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)
|
||||
for: 10m
|
||||
labels: {severity: critical}
|
||||
@@ -189,3 +192,9 @@ groups:
|
||||
labels: {severity: warning}
|
||||
annotations:
|
||||
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" \
|
||||
--backup-dir "$ROOT/archive/$TS" \
|
||||
--exclude "/Personal Vault/**" \
|
||||
--metadata-exclude "malware-detected=true" --onedrive-av-override=false \
|
||||
--transfers 4 --checkers 8 \
|
||||
--retries 5 --low-level-retries 20 \
|
||||
--log-file "$LOG" --log-level INFO \
|
||||
|
||||
@@ -10,6 +10,7 @@ mountpoint -q /mnt/backup || { echo "/mnt/backup не смонтирован —
|
||||
rc=0
|
||||
|
||||
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" \
|
||||
--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 [шаг ...]
|
||||
Шаги: qbit jellyfin arr prowlarr bazarr seerr connect homepage (без аргументов — все).
|
||||
Шаги: qbit jellyfin arr langs prowlarr bazarr seerr connect homepage (без аргументов — все).
|
||||
Пароль админа берётся из /opt/media/.admin-password, API-ключи — из config.xml.
|
||||
"""
|
||||
from http.cookiejar import CookieJar
|
||||
@@ -187,6 +187,48 @@ def arr():
|
||||
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
|
||||
def prowlarr():
|
||||
k = apikey("prowlarr")
|
||||
@@ -385,7 +427,7 @@ def jellyseerr():
|
||||
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__":
|
||||
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`.
|
||||
|
||||
Печать по просьбе ребёнка (уточнение владельца 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,75 @@
|
||||
# Выгрузка 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 |
|
||||
| Чаты | **все кипрские группы и каналы** (владелец 2026-10-18): `--kinds group supergroup channel --only кипр cyprus @…` — всё с «кипр»/«cyprus» в названии (подстрока, без регистра; новые кипрские группы — сами) плюс явные @username без этих слов (@cylaw, @cymedic, @cyprusinet, @cyschools — заявка ждёт админа, @cyfasttrack, …), Cyprus Car – Chat (`-1001315261949`). Личные переписки и боты — нет. История — **с 2024-10-18**. ⚠️ Выгружаются только чаты, где аккаунт состоит; в группах со скрытой историей — только с даты вступления (CypRus Helpers — с 2026-06-20) |
|
||||
| Таймер | `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`).
|
||||
- **Скорость** (2026-10-18, 85 тыс. кусков): векторы всех кусков — `index.emb.npz` рядом с индексом (пересобирается после
|
||||
индексации; из SQLite на HDD было ~40 с на запрос); модель держит служба **`tg-embed`** (astra, сокет
|
||||
`/run/tg-embed/embed.sock` 0600, ~140 МБ RSS) — загрузка модели в процесс ~11 с (fastembed/ONNX; model2vec ещё
|
||||
медленнее, 28 с — проверено, векторы те же). Итого `tg search` ~6 с. Нет службы — `tg` грузит модель сам.
|
||||
- `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` — посмотреть.
|
||||
- **Вечерний рекап группы целиком** (с 2026-10-18): `tg digest add @чат` (список — `/var/lib/tg-report/digests.json`),
|
||||
`tg-digest.timer` 23:50 Asia/Nicosia → `tg digest run`: все куски чата **с прошлого рекапа** (не календарный день —
|
||||
сообщения после последней ежечасной докачки уйдут в следующий рекап, без дыр), до 60 тыс. знаков (больше — последние),
|
||||
Codex пишет темы дня и «Полезное» со ссылками → очередь outbox. Сейчас: CY Fast Track Talks (@cyfasttrack).
|
||||
`tg digest run --dry --hours 24` — посмотреть.
|
||||
- **Участники группы с аватарками** — `app/tg_members.py` (`/opt/tg-export/tg_members.py @чат`, от tgexport, ⚠️ не параллельно
|
||||
с `tg-export.service` — одна сессия): `/mnt/backup/tg/members/<id>_<название>/members.{json,csv}` + `avatars/<id>.jpg`
|
||||
(маленькие). Персональные данные участников — в поиск/индекс не идут. 2026-10-18: CY Fast Track Talks — 2774 из 2774,
|
||||
аватарок 1619. Разовая выгрузка, не по таймеру.
|
||||
- **Личные каналы участников** — `app/tg_bios.py`: bio и «личный канал» каждого участника (GetFullUser, ~1 запрос/с —
|
||||
2765 профилей за ~55 мин без ограничений) → `bios.json`. 2026-10-18 CY Fast Track Talks: личный канал у 145 из 2765,
|
||||
топ — `top-personal-channels.txt` в папке участников. ⚠️ Второй шаг скрипта (размер каждой ссылки через
|
||||
`get_entity` = ResolveUsername) после ~190 запросов получил **FloodWait 77 545 с (~22 ч)** — остановлен; размеры
|
||||
публичных каналов взяты со страниц `t.me/<username>` (без сессии). Массово резолвить username через API нельзя —
|
||||
брать `t.me`-страницы или `full.chats` из GetFullUser.
|
||||
@@ -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,106 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Ссылки на группы/каналы в профилях участников (владелец 2026-10-18): по members.json группы — bio и «личный канал»
|
||||
каждого участника (GetFullUser, ~1 запрос/с), затем размер каждой найденной группы/канала → топ по участникам.
|
||||
|
||||
sudo -u tgexport sh -c '. /etc/tg-export/env; /opt/tg-export/venv/bin/python /opt/tg-export/tg_bios.py <папка members>'
|
||||
|
||||
Результат в той же папке: bios.json (id → bio, личный канал; докачка с места остановки), links.json (группа/канал →
|
||||
кто ссылается, размер), top.txt. ⚠️ Не параллельно с tg-export.service (одна сессия).
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from telethon import TelegramClient, errors
|
||||
from telethon.tl.functions.channels import GetFullChannelRequest
|
||||
from telethon.tl.functions.users import GetFullUserRequest
|
||||
|
||||
LINK = re.compile(r"(?:https?://)?(?:t\.me|telegram\.me)/(?:s/)?([A-Za-z][\w]{3,31})(?![\w/])|(?<![\w@])@([A-Za-z][\w]{3,31})")
|
||||
SKIP = {"joinchat", "addstickers", "share", "proxy", "socks", "iv", "c", "addlist", "boost"}
|
||||
|
||||
|
||||
async def main(folder):
|
||||
folder = Path(folder)
|
||||
members = json.loads((folder / "members.json").read_text())["members"]
|
||||
bios_f = folder / "bios.json"
|
||||
bios = json.loads(bios_f.read_text()) if bios_f.exists() else {}
|
||||
c = TelegramClient("/var/lib/tg-export/tg.session", int(os.environ["TG_API_ID"]), os.environ["TG_API_HASH"])
|
||||
c.flood_sleep_threshold = 0 # FloodWait ловим сами, с логом
|
||||
await c.start()
|
||||
try:
|
||||
todo = [m for m in members if not m["bot"] and not m["deleted"] and str(m["id"]) not in bios]
|
||||
print(f"профилей: {len(members)}, уже есть {len(bios)}, осталось {len(todo)}", flush=True)
|
||||
for i, m in enumerate(todo, 1):
|
||||
while True:
|
||||
try:
|
||||
full = await c(GetFullUserRequest(m["id"]))
|
||||
break
|
||||
except errors.FloodWaitError as e:
|
||||
print(f" FloodWait {e.seconds} с на {i}/{len(todo)}", flush=True)
|
||||
await asyncio.sleep(e.seconds + 2)
|
||||
except Exception as e:
|
||||
full = None
|
||||
print(f" {m['id']}: {type(e).__name__}", flush=True)
|
||||
break
|
||||
rec = {"username": m["username"], "bio": "", "channel": None}
|
||||
if full:
|
||||
rec["bio"] = full.full_user.about or ""
|
||||
pc = getattr(full.full_user, "personal_channel_id", None)
|
||||
if pc:
|
||||
ch = next((x for x in full.chats if x.id == pc), None)
|
||||
rec["channel"] = {"id": pc, "username": getattr(ch, "username", None), "title": getattr(ch, "title", None)}
|
||||
bios[str(m["id"])] = rec
|
||||
if i % 50 == 0:
|
||||
bios_f.write_text(json.dumps(bios, ensure_ascii=False))
|
||||
print(f" {i}/{len(todo)}", flush=True)
|
||||
await asyncio.sleep(1.0)
|
||||
bios_f.write_text(json.dumps(bios, ensure_ascii=False))
|
||||
|
||||
# ссылки: username → кто ссылается
|
||||
refs = {}
|
||||
for uid, r in bios.items():
|
||||
found = set()
|
||||
for a, b in LINK.findall(r["bio"] or ""):
|
||||
u = (a or b).lower()
|
||||
if u not in SKIP:
|
||||
found.add(u)
|
||||
if r["channel"] and r["channel"].get("username"):
|
||||
found.add(r["channel"]["username"].lower())
|
||||
for u in found:
|
||||
refs.setdefault(u, set()).add(r["username"] or uid)
|
||||
print(f"уникальных ссылок: {len(refs)}", flush=True)
|
||||
links = {}
|
||||
for i, (u, who) in enumerate(sorted(refs.items()), 1):
|
||||
info = {"refs": sorted(who), "kind": None, "title": None, "members": None}
|
||||
while True:
|
||||
try:
|
||||
e = await c.get_entity(u)
|
||||
if getattr(e, "broadcast", None) is not None or getattr(e, "megagroup", None) is not None:
|
||||
full = await c(GetFullChannelRequest(e))
|
||||
info.update(kind="channel" if e.broadcast else "group", title=e.title,
|
||||
members=full.full_chat.participants_count)
|
||||
else:
|
||||
info["kind"] = "user" if hasattr(e, "first_name") else type(e).__name__
|
||||
break
|
||||
except errors.FloodWaitError as ex:
|
||||
print(f" FloodWait {ex.seconds} с на ссылке {i}", flush=True)
|
||||
await asyncio.sleep(ex.seconds + 2)
|
||||
except Exception as ex:
|
||||
info["kind"] = f"error:{type(ex).__name__}"
|
||||
break
|
||||
links[u] = info
|
||||
await asyncio.sleep(1.5)
|
||||
(folder / "links.json").write_text(json.dumps(links, ensure_ascii=False, indent=1))
|
||||
top = sorted(((u, x) for u, x in links.items() if x["members"]), key=lambda t: -t[1]["members"])
|
||||
lines = [f"{n}. {x['title']} (@{u}) — {x['kind']}, {x['members']} участников; в профилях: {', '.join('@' + w if not w.isdigit() else w for w in x['refs'][:5])}"
|
||||
for n, (u, x) in enumerate(top[:30], 1)]
|
||||
(folder / "top.txt").write_text("\n".join(lines) + "\n")
|
||||
print("\n".join(lines[:10]))
|
||||
finally:
|
||||
await c.disconnect()
|
||||
|
||||
|
||||
asyncio.run(main(sys.argv[1]))
|
||||
@@ -0,0 +1,76 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Участники группы с аватарками (владелец 2026-10-18): /mnt/backup/tg/members/<id>_<название>/
|
||||
members.json и members.csv (id, username, имя, бот/премиум, статус, есть ли фото), avatars/<id>.jpg (маленькие).
|
||||
|
||||
sudo -u tgexport sh -c '. /etc/tg-export/env; /opt/tg-export/venv/bin/python /opt/tg-export/tg_members.py @cyfasttrack'
|
||||
|
||||
⚠️ Не запускать одновременно с tg-export.service (одна сессия). Если админы скрыли список участников — Telegram
|
||||
отдаст только админов. Это персональные данные участников: в поиск/индекс не идут.
|
||||
"""
|
||||
import asyncio
|
||||
import csv
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from telethon import TelegramClient, errors
|
||||
|
||||
import export as ex
|
||||
|
||||
OUT = Path(os.environ.get("TG_OUT", "/mnt/backup/tg")) / "members"
|
||||
|
||||
|
||||
def status_of(u):
|
||||
s = getattr(u, "status", None)
|
||||
if s is None:
|
||||
return ""
|
||||
name = type(s).__name__.replace("UserStatus", "")
|
||||
when = getattr(s, "was_online", None)
|
||||
return f"{name} {when.isoformat()}" if when else name
|
||||
|
||||
|
||||
async def main(chat):
|
||||
client = TelegramClient("/var/lib/tg-export/tg.session", int(os.environ["TG_API_ID"]), os.environ["TG_API_HASH"])
|
||||
client.flood_sleep_threshold = 3600
|
||||
await client.start()
|
||||
try:
|
||||
ent = await client.get_entity(chat)
|
||||
folder = OUT / f"-100{ent.id}_{ex.slugify(getattr(ent, 'title', chat))}"
|
||||
av = folder / "avatars"
|
||||
av.mkdir(parents=True, exist_ok=True)
|
||||
people = await client.get_participants(ent, aggressive=True)
|
||||
rows = []
|
||||
for i, u in enumerate(people, 1):
|
||||
photo = ""
|
||||
if getattr(u, "photo", None):
|
||||
dest = av / f"{u.id}.jpg"
|
||||
if not dest.exists():
|
||||
try:
|
||||
await client.download_profile_photo(u, file=str(dest), download_big=False)
|
||||
except errors.FloodWaitError as e:
|
||||
await asyncio.sleep(e.seconds + 1)
|
||||
except Exception as e: # фото недоступно — пропускаем
|
||||
print(f" {u.id}: фото не скачалось ({type(e).__name__})", flush=True)
|
||||
photo = dest.name if dest.exists() else ""
|
||||
p = getattr(u, "participant", None)
|
||||
rows.append({"id": u.id, "username": u.username or "", "first_name": u.first_name or "",
|
||||
"last_name": u.last_name or "", "bot": bool(u.bot), "premium": bool(getattr(u, "premium", False)),
|
||||
"deleted": bool(u.deleted), "status": status_of(u),
|
||||
"role": type(p).__name__.replace("ChannelParticipant", "") if p else "",
|
||||
"joined": p.date.isoformat() if getattr(p, "date", None) else "", "avatar": photo})
|
||||
if i % 100 == 0:
|
||||
print(f" {i}/{len(people)}", flush=True)
|
||||
(folder / "members.json").write_text(json.dumps({"chat": getattr(ent, "title", chat), "id": f"-100{ent.id}",
|
||||
"count": len(rows), "members": rows}, ensure_ascii=False, indent=1))
|
||||
with (folder / "members.csv").open("w", newline="", encoding="utf-8") as f:
|
||||
w = csv.DictWriter(f, fieldnames=list(rows[0].keys()) if rows else ["id"])
|
||||
w.writeheader()
|
||||
w.writerows(rows)
|
||||
total = getattr(await client.get_participants(ent, limit=0), "total", None)
|
||||
print(f"{folder.name}: участников получено {len(rows)} (в группе всего {total}), аватарок {sum(1 for r in rows if r['avatar'])}")
|
||||
finally:
|
||||
await client.disconnect()
|
||||
|
||||
|
||||
asyncio.run(main(sys.argv[1]))
|
||||
@@ -0,0 +1,567 @@
|
||||
#!/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 digest add @ЧАТ | list | del @ЧАТ чаты для ежедневного рекапа целиком (всё за день, а не по темам)
|
||||
tg digest run [--hours N] [--dry] рекап этих чатов с прошлого рекапа → владельцу в Telegram (таймер 23:50)
|
||||
tg stats сколько чатов/сообщений/кусков, когда обновлялось
|
||||
tg index (сервис tg-export, юзер tgexport) — дописать в индекс новые сообщения выгрузки
|
||||
tg serve (служба tg-embed, юзер astra) — держать модель в памяти, векторы запросов по unix-сокету
|
||||
|
||||
Индекс — /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) — сводку режем на части
|
||||
# векторы всех кусков одним файлом рядом с индексом: чтение 85 тыс. BLOB из SQLite на HDD — ~40 с на запрос,
|
||||
# а np.load(mmap) — доли секунды; пересобирается после каждой индексации
|
||||
CACHE = DB.with_suffix(".emb.npz")
|
||||
|
||||
_emb = None
|
||||
SOCK = os.environ.get("TG_EMBED_SOCK", "/run/tg-embed/embed.sock")
|
||||
|
||||
|
||||
def _embed_remote(texts):
|
||||
"""Векторы от службы tg-embed (модель уже в памяти) — иначе None. Загрузка модели в процесс — ~11 с на Pi 4."""
|
||||
import socket
|
||||
import numpy as np
|
||||
if not os.path.exists(SOCK):
|
||||
return None
|
||||
try:
|
||||
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as s:
|
||||
s.settimeout(30)
|
||||
s.connect(SOCK)
|
||||
s.sendall(json.dumps({"texts": texts}).encode() + b"\n")
|
||||
buf = b""
|
||||
while not buf.endswith(b"\n"):
|
||||
part = s.recv(1 << 20)
|
||||
if not part:
|
||||
break
|
||||
buf += part
|
||||
r = json.loads(buf)
|
||||
return np.frombuffer(bytes.fromhex(r["vecs"]), dtype=np.float32).reshape(r["n"], r["dim"])
|
||||
except (OSError, ValueError, KeyError):
|
||||
return None
|
||||
|
||||
|
||||
def embed(texts, remote=True):
|
||||
global _emb
|
||||
import numpy as np
|
||||
if remote and len(texts) <= 64:
|
||||
v = _embed_remote(list(texts))
|
||||
if v is not None:
|
||||
return v
|
||||
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()
|
||||
if total or not CACHE.exists():
|
||||
build_cache(c)
|
||||
print(f"проиндексировано новых сообщений: {total}")
|
||||
|
||||
|
||||
def build_cache(c):
|
||||
import numpy as np
|
||||
rows = c.execute("SELECT id, chat_id, ts1, emb FROM chunk ORDER BY id").fetchall()
|
||||
if not rows:
|
||||
return
|
||||
tmp = CACHE.with_name(CACHE.name + ".tmp.npz")
|
||||
np.savez(tmp, ids=np.array([r["id"] for r in rows], dtype=np.int64),
|
||||
chat=np.array([r["chat_id"] for r in rows], dtype=np.int64),
|
||||
ts=np.array([r["ts1"] for r in rows], dtype=np.int64),
|
||||
emb=np.frombuffer(b"".join(r["emb"] for r in rows), dtype=np.float32).reshape(len(rows), -1))
|
||||
os.replace(tmp, CACHE)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- поиск
|
||||
|
||||
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]
|
||||
if CACHE.exists():
|
||||
z = np.load(CACHE)
|
||||
ids, emb, mask = z["ids"], z["emb"], np.ones(len(z["ids"]), dtype=bool)
|
||||
if since:
|
||||
mask &= z["ts"] >= since
|
||||
if chat:
|
||||
cids = [r[0] for r in c.execute("SELECT id FROM chat WHERE title LIKE ? OR username LIKE ?", (f"%{chat}%", f"%{chat}%"))]
|
||||
mask &= np.isin(z["chat"], cids)
|
||||
ids, emb = ids[mask], emb[mask]
|
||||
else: # кэша нет (старый индекс) — медленно, из базы
|
||||
rows = c.execute(f"SELECT c.id, c.emb FROM chunk c WHERE {w}", args).fetchall()
|
||||
ids = np.array([r["id"] for r in rows], dtype=np.int64)
|
||||
emb = np.frombuffer(b"".join(r["emb"] for r in rows), dtype=np.float32).reshape(len(rows), -1) if rows else None
|
||||
if len(ids):
|
||||
s = emb @ qv
|
||||
top_i = np.argpartition(-s, min(60, len(s) - 1))[:60]
|
||||
for r, i in enumerate(top_i[np.argsort(-s[top_i])]):
|
||||
sims[int(ids[i])] = float(s[i])
|
||||
ranks[int(ids[i])] = ranks.get(int(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}"""
|
||||
|
||||
|
||||
DIGEST_PROMPT = """Ты готовишь владельцу вечерний рекап Telegram-группы «{title}» за период {period}.
|
||||
Ниже — вся переписка группы за этот период по кускам (время, ссылка на первое сообщение куска, текст «Автор: сообщение»).
|
||||
|
||||
Сделай рекап по-русски, простым текстом для Telegram без Markdown:
|
||||
- 3–8 главных тем дня, по каждой — 1–3 строки сути: о чём спорили, что выяснили, какие советы и ответы дали;
|
||||
- отдельно «Полезное»: конкретика — места, контакты, цены, сроки, ссылки, документы, лайфхаки — с ссылкой на сообщение;
|
||||
- если был важный вопрос без ответа — упомяни;
|
||||
- флуд, приветствия, рекламу и оффтоп пропусти. Ничего не выдумывай, только по переписке.
|
||||
Темы — строкой «▸ Тема», пункты — «• …». Без вступлений и выводов. Если переписки почти не было — одной строкой.
|
||||
|
||||
{data}"""
|
||||
DIGEST_MAX = 60_000 # знаков переписки в один запрос к Codex (больше — берём последние)
|
||||
|
||||
|
||||
def digests():
|
||||
f = REPORT / "digests.json"
|
||||
return json.loads(f.read_text()) if f.exists() else {"chats": [], "last": {}}
|
||||
|
||||
|
||||
def save_digests(d):
|
||||
REPORT.mkdir(parents=True, exist_ok=True)
|
||||
(REPORT / "digests.json").write_text(json.dumps(d, ensure_ascii=False, indent=1))
|
||||
|
||||
|
||||
def find_chat(c, needle):
|
||||
n = needle.lstrip("@").lower()
|
||||
for r in c.execute("SELECT id, title, username FROM chat"):
|
||||
if (r["username"] or "").lower() == n or str(r["id"]) == needle or n in (r["title"] or "").lower():
|
||||
return r
|
||||
return None
|
||||
|
||||
|
||||
def queue_text(text, tag):
|
||||
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}-{tag}{i:02d}.txt").write_text(p.strip())
|
||||
finally:
|
||||
os.umask(old)
|
||||
return len(parts)
|
||||
|
||||
|
||||
def cmd_digest(a):
|
||||
d = digests()
|
||||
if a.op == "list":
|
||||
for x in d["chats"]:
|
||||
last = d["last"].get(str(x["id"]))
|
||||
print(f"{x['title']} (@{x.get('username') or x['id']})" + (f" — последний рекап {dt.datetime.fromtimestamp(last, TZ):%d.%m %H:%M}" if last else ""))
|
||||
if not d["chats"]:
|
||||
print("Чатов для рекапа нет. Добавить: tg digest add @чат")
|
||||
return
|
||||
if a.op in ("add", "del"):
|
||||
c = db()
|
||||
r = find_chat(c, a.chat)
|
||||
if a.op == "add":
|
||||
if not r:
|
||||
raise SystemExit(f"{a.chat} нет в индексе — чат выгружается? (tg stats)")
|
||||
if all(x["id"] != r["id"] for x in d["chats"]):
|
||||
d["chats"].append({"id": r["id"], "title": r["title"], "username": r["username"]})
|
||||
print(f"Ежедневный рекап: {r['title']}")
|
||||
else:
|
||||
before = len(d["chats"])
|
||||
d["chats"] = [x for x in d["chats"] if not (r and x["id"] == r["id"]) and x.get("username") != a.chat.lstrip("@")]
|
||||
print("Убран." if len(d["chats"]) < before else "Такого чата в рекапе нет.")
|
||||
save_digests(d)
|
||||
return
|
||||
# run
|
||||
if not d["chats"]:
|
||||
print("Чатов для рекапа нет.")
|
||||
return
|
||||
if not DB.exists():
|
||||
print("Индекса ещё нет — рекап не делаю.")
|
||||
return
|
||||
c = db()
|
||||
now = int(time.time())
|
||||
for x in d["chats"]:
|
||||
since = now - a.hours * 3600 if a.hours else d["last"].get(str(x["id"]), now - 86400)
|
||||
rows = c.execute("SELECT * FROM chunk WHERE chat_id=? AND ts1 > ? ORDER BY ts0", (x["id"], since)).fetchall()
|
||||
chat = {"id": x["id"], "username": x.get("username")}
|
||||
blocks, total = [], 0
|
||||
for r in reversed(rows): # не влезает — берём последние
|
||||
b = f"── {dt.datetime.fromtimestamp(r['ts0'], TZ):%d.%m %H:%M} · {link(chat, r['first_id'])}\n{r['text']}"
|
||||
if total + len(b) > DIGEST_MAX:
|
||||
break
|
||||
blocks.append(b)
|
||||
total += len(b)
|
||||
blocks.reverse()
|
||||
period = f"{dt.datetime.fromtimestamp(since, TZ):%d.%m %H:%M} — {dt.datetime.fromtimestamp(now, TZ):%d.%m %H:%M}"
|
||||
n_msgs = sum(r["n"] for r in rows)
|
||||
if not rows:
|
||||
text = f"🗒 {x['title']} за {period}: сообщений не было."
|
||||
else:
|
||||
body = codex(DIGEST_PROMPT.format(title=x["title"], period=period, data="\n\n".join(blocks)))
|
||||
cut = f" (в рекапе последние {len(blocks)} из {len(rows)} кусков)" if len(blocks) < len(rows) else ""
|
||||
text = f"🗒 {x['title']} — рекап за {period}, сообщений {n_msgs}{cut}\n\n{body}"
|
||||
if a.dry:
|
||||
print(text)
|
||||
continue
|
||||
print(f"{x['title']}: в очереди {queue_text(text, 'dg')} сообщ.")
|
||||
d["last"][str(x["id"])] = now
|
||||
save_digests(d)
|
||||
|
||||
|
||||
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
|
||||
n = queue_text(text, "tg")
|
||||
REPORT.mkdir(parents=True, exist_ok=True)
|
||||
(REPORT / "last-report.txt").write_text(text)
|
||||
print(f"сводка поставлена в очередь: {n} сообщ.")
|
||||
|
||||
|
||||
def cmd_serve(a):
|
||||
"""Служба tg-embed: модель в памяти, векторы запросов по unix-сокету (одна строка JSON → одна строка JSON)."""
|
||||
import socketserver
|
||||
embed(["прогрев"], remote=False)
|
||||
|
||||
class H(socketserver.StreamRequestHandler):
|
||||
def handle(self):
|
||||
try:
|
||||
req = json.loads(self.rfile.readline())
|
||||
v = embed(req["texts"][:64], remote=False)
|
||||
out = {"n": v.shape[0], "dim": v.shape[1], "vecs": v.astype("float32").tobytes().hex()}
|
||||
except Exception as e: # плохой запрос не роняет службу
|
||||
out = {"error": str(e)}
|
||||
self.wfile.write(json.dumps(out).encode() + b"\n")
|
||||
|
||||
if os.path.exists(SOCK):
|
||||
os.unlink(SOCK)
|
||||
old = os.umask(0o077) # сокет — только своему пользователю (astra)
|
||||
srv = socketserver.UnixStreamServer(SOCK, H)
|
||||
os.umask(old)
|
||||
print(f"tg-embed: {EMB_MODEL} в памяти, сокет {SOCK}", flush=True)
|
||||
srv.serve_forever()
|
||||
|
||||
|
||||
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")
|
||||
s = sub.add_parser("digest"); ds = s.add_subparsers(dest="op", required=True)
|
||||
r = ds.add_parser("add"); r.add_argument("chat")
|
||||
r = ds.add_parser("del"); r.add_argument("chat")
|
||||
ds.add_parser("list")
|
||||
r = ds.add_parser("run"); r.add_argument("--hours", type=int, default=0); r.add_argument("--dry", action="store_true")
|
||||
sub.add_parser("stats")
|
||||
s = sub.add_parser("index"); s.add_argument("--quiet", action="store_true")
|
||||
sub.add_parser("serve")
|
||||
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,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 digest run
|
||||
TimeoutStartSec=30min
|
||||
@@ -0,0 +1,9 @@
|
||||
[Unit]
|
||||
Description=Telegram: рекап групп каждый день в 23:50 (Кипр)
|
||||
|
||||
[Timer]
|
||||
OnCalendar=*-*-* 23:50 Asia/Nicosia
|
||||
Persistent=true
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
@@ -0,0 +1,17 @@
|
||||
[Unit]
|
||||
Description=Telegram-поиск: модель эмбеддингов в памяти (векторы запросов tg search/report за доли секунды)
|
||||
|
||||
[Service]
|
||||
User=astra
|
||||
Group=astra
|
||||
# сокет /run/tg-embed/embed.sock, только astra (0700 каталог, 0600 сокет)
|
||||
RuntimeDirectory=tg-embed
|
||||
RuntimeDirectoryMode=0700
|
||||
ExecStart=/opt/tg-export/venv/bin/python /opt/tg-export/tgrag.py serve
|
||||
Restart=on-failure
|
||||
RestartSec=30
|
||||
Nice=5
|
||||
MemoryMax=1500M
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,23 @@
|
||||
[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): в названии «кипр»/«cyprus» (подстрока, без регистра — новые кипрские
|
||||
# группы подхватываются сами) + явные @username без этих слов; только группы/каналы — не личные и не боты (@CyprusCarBot).
|
||||
# Не вступивший чат --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 --kinds group supergroup channel --only кипр cyprus @cylaw @cymedic @cyprusinet @cyschools @cyvisaorg @cylang @cyprusdiy @CypRusSale @CupRusHelpers @badcyprus @cyprusit @cyprussport @cyfasttrack -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