Compare commits
13
Commits
9a82a6e639
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce23ac2824 | ||
|
|
5e6df382c8 | ||
|
|
69ad9cf123 | ||
|
|
1a525e9073 | ||
|
|
15a9e1d2de | ||
|
|
dfe0fad728 | ||
|
|
3bdb75d465 | ||
|
|
48c129615f | ||
|
|
e327b53496 | ||
|
|
4da6260433 | ||
|
|
f7adfb15c3 | ||
|
|
a09106648b | ||
|
|
d13a05510c |
@@ -39,13 +39,13 @@ _Собрано 2026-10-11._
|
||||
- [ ] 5. Скилл для собственных проектов
|
||||
- [x] 6. Скилл для ведения собственных дел (2026-10-13: скилл `life`, `Дела насущные/`, см. `servers/rpi.md`)
|
||||
- [ ] 7. Скрипты для работы с чатами — выжимки и слежение за интересующей информацией
|
||||
- [ ] 8. Собрать промежуточные точки доступа (mesh D-Link M15 ×2, E15) и настроить скиллы для других устройств
|
||||
- [x] 8. Собрать промежуточные точки доступа (mesh D-Link M15 ×2, E15) и настроить скиллы для других устройств — ✅ готово (владелец, 2026-10-18)
|
||||
- [ ] 12. Опознать `.46` (`ea:08:6d:8c:59:56`, iOS без имени) и подтвердить, что `.13` — iPhone жены
|
||||
- [ ] 13. Подключить календарь для Полины
|
||||
- [x] 13. Подключить календарь для Полины — ✅ готово (владелец, 2026-10-18)
|
||||
- [x] 14. Подключить к почте Astra (индекс, классы, метки, ожидаемые письма) остальные ящики владельца —
|
||||
**ruzzyrullezz@gmail.com** подключён 2026-10-16 (см. `servers/rpi.md`, «Второй ящик»); другие ящики не нужны (владелец 2026-10-17).
|
||||
Для Gmail — тот же OAuth-клиент проекта `astra`, второй токен; ⚠️ квота ~100 писем/мин на пользователя — первичная индексация долгая.
|
||||
- [ ] 15. **MCP ко всему этому** — подключать Claude (claude.ai web/Desktop/телефон, Claude Code, Astra) к книгам и прочему.
|
||||
- [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.
|
||||
|
||||
@@ -178,6 +178,7 @@ backend=systemd banaction=ufw
|
||||
- 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`.
|
||||
|
||||
|
||||
+4
-1
@@ -247,6 +247,7 @@ Docker CE 29.9 + Compose v5.6 из репо download.docker.com (в Debian ст
|
||||
|
||||
- 2026-10-12 08:16 MSK по прямой просьбе владельца вручную запущен `onedrive-backup.service` после установки фильтра malware-detected. Перед запуском служба была failed (предыдущий результат 04:57), конкурирующего прогона не было; запуск принят, состояние activating/start. Итог и обновление метрики успеха ещё ожидаются, алерт вручную не сбрасывался.
|
||||
|
||||
- **Место на 1-ТБ диске роутера** (2026-10-18: 409 из 932 ГБ, 44 %): растут архивы серверов (⚠️ на роутере ежемесячные — полные копии: hardlink'и малины SFTP не переносит) и вечный архив версий OneDrive; прогноз ~600 ГБ (65 %) через год. Действий не нужно — сработает `BackupDiskFull` (>90 %, метрика `/mnt/router` в Prometheus проверена 2026-10-18). Варианты, когда понадобится: не копировать на роутер ежемесячные архивы (−~100 ГБ), срок хранения архива версий на роутере, диск больше.
|
||||
|
||||
## Google: календарь и почта владельца (с 2026-10-12)
|
||||
|
||||
@@ -298,10 +299,12 @@ Docker CE 29.9 + Compose v5.6 из репо download.docker.com (в Debian ст
|
||||
- Проект `tg_export` (Telethon) перенесён с Mac: код, юниты и установка — [`stacks/rpi-tg-export/`](../stacks/rpi-tg-export/README.md). На малине — **второй аккаунт владельца**, докачка **только текста** раз в сутки (`tg-export.timer`, 04:30, `run --no-media --archived`), выгрузка в `/mnt/backup/tg/` (tgexport:astra 2750/640 — Astra читает). Старая выгрузка первого аккаунта (~14 ГБ) — на Mac, не переносилась (решение владельца).
|
||||
- Отдельный системный пользователь `tgexport` (без шелла): сессия `/var/lib/tg-export/tg.session` (600) = полный доступ к аккаунту — Astra её не видит; ключи приложения `/etc/tg-export/env` (root:tgexport 640, те же `api_id`/`api_hash`, что на Mac). Вход — владелец: `ssh -t rpi sudo -u tgexport tg-export login`.
|
||||
- Мониторинг — `backup-metrics backup_tg_export` → `BackupFailed`/`BackupStale` (>30 ч). ⚠️ Первый прогон может идти дольше суток из-за FloodWait.
|
||||
- **Поиск и сводка** (с 2026-10-18): докачка каждый час (было раз в сутки 04:30) + индекс `/mnt/backup/tg/index.db` (FTS5 + эмбеддинги potion-multilingual-128M, fastembed в venv, модели `/opt/tg-export/models`); `tg search` для Astra, темы `tg topic`, сводка `tg-report.timer` 08:00 Asia/Nicosia через Codex → очередь outbox. Навык и правила — astra-bot `11dd4fc`. Подробно — README стека. Вход во второй аккаунт (@mikpokrovsky) — 2026-10-18; выгружаются только 6 выбранных владельцем чатов (`--only`, см. README стека). ⚠️ 2026-10-18 18:45–18:51 по ошибке шла выгрузка всех групп и каналов (успела только группа Python, 35 500 сообщений) — остановлена, данные удалены по решению владельца. ⚠️ Таймер выгрузки **выключен** до вступления владельца в @cyprusdiy, @cymedic и Cyprus Car – Chat; сводка включена, без тем и индекса молчит.
|
||||
- **Поиск и сводка** (с 2026-10-18): докачка каждый час (было раз в сутки 04:30) + индекс `/mnt/backup/tg/index.db` (FTS5 + эмбеддинги potion-multilingual-128M, fastembed в venv, модели `/opt/tg-export/models`); `tg search` для Astra, темы `tg topic`, сводка `tg-report.timer` 08:00 Asia/Nicosia через Codex → очередь outbox. Навык и правила — astra-bot `11dd4fc`. Вечерний рекап группы целиком — `tg-digest.timer` 23:50 Asia/Nicosia (`tg digest`, сейчас @cyfasttrack). Служба `tg-embed` (astra) держит модель эмбеддингов в памяти для быстрых запросов (~6 с вместо ~40). Первая полная выгрузка 2026-10-18 19:49: 12 чатов, 303 тыс. сообщений с 2024-10-18, индекс 85 580 кусков, 1,2 ГБ на HDD. Подробно — README стека. Вход во второй аккаунт (@mikpokrovsky) — 2026-10-18; выгружаются только 6 выбранных владельцем чатов (`--only`, см. README стека). ⚠️ 2026-10-18 18:45–18:51 по ошибке шла выгрузка всех групп и каналов (успела только группа Python, 35 500 сообщений) — остановлена, данные удалены по решению владельца. ⚠️ Таймер выгрузки **выключен** до вступления владельца в @cyprusdiy, @cymedic и Cyprus Car – Chat; сводка включена, без тем и индекса молчит.
|
||||
- Доступ: тексты — только для владельца; Оле и Поле закрыто (правило в `codex/AGENTS.md` и скилл `tg`, astra-bot `27bf8e6`), как почта.
|
||||
|
||||
## Читалка books → Obsidian (с 2026-10-18)
|
||||
|
||||
- Заметки и рекапы из books.ruzzy.dev (диалоги с ИИ — только через кнопку «В заметки», целиком не выгружаются) → vault `Книги/` (файл на книгу) раз в 10 мин: `books-sync.timer` (astra) тянет `GET /api/export` с токеном `export:obsidian-rpi` (`/etc/books-sync/token`, root:astra 640; на fr1 только хэш). Export-токены читалка пускает только на `/api/export`. Подробно — [`stacks/rpi-books-sync/`](../stacks/rpi-books-sync/README.md).
|
||||
- Односторонняя: выше `## Мои мысли` перезаписывается, раздел владельца сохраняется. Первая синхронизация 2026-10-18 00:38 — «Efficient Go», «Observability Engineering».
|
||||
|
||||
- 2026-10-19 установлен astra-bot `8401f4d`: проверки очереди и завершения домашней печати через `lpstat` больше не требуют кнопок, включая чат Поли (раньше запрос уходил владельцу). Разрешены только локальный lpstat и ограниченные аргументы домашней очереди; удалённый сервер, shell-выражения и другие команды не разрешены. Все 142 теста прошли, включая исходную команду в bash-обёртке и отклонение подмены/дополнительных действий. Старый bot.py сохранён `.bak-20261019-printer-status`; перезапуск с проверкой готовности отложен на 60 секунд через `astra-printer-status-restart`, результат готовности приходит владельцу.
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
| Ключи приложения (`TG_API_ID`/`TG_API_HASH`, my.telegram.org) | `/etc/tg-export/env` — root:tgexport 640, в git нет |
|
||||
| Сессия (= **полный доступ к аккаунту**) | `/var/lib/tg-export/tg.session` — tgexport 600 |
|
||||
| Выгрузка | `/mnt/backup/tg/` — `dialogs.json`, `chats/<id>_<название>/{messages.jsonl,messages.txt,meta.json,_state.json}`; tgexport:astra, каталоги 2750, файлы 640 |
|
||||
| Чаты | только выбранные владельцем (`--only` в юните, 2026-10-18): @CupRusHelpers, Cyprus Car – Chat (`-1001315261949`), @cyprusdiy, @cylaw, @cymedic, @badcyprus. Личные переписки и боты (в т.ч. рабочие) — нет. История — **с 2024-10-18** (`--since`, последние 2 года на момент запуска; дата фиксирована, дальше копится всё новое). ⚠️ В группах со скрытой для новых участников историей видно только с даты вступления (CypRus Helpers — с 2026-06-20). ⚠️ Выгружаются только чаты, где аккаунт состоит: не вступивший `--only` молча пропускает |
|
||||
| Чаты | **все кипрские группы и каналы** (владелец 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 ч) |
|
||||
|
||||
@@ -45,6 +45,10 @@ sudo systemctl enable --now tg-export.timer
|
||||
Сравнение на 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 раза.
|
||||
@@ -54,3 +58,18 @@ sudo systemctl enable --now tg-export.timer
|
||||
не лимит 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,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]))
|
||||
@@ -6,8 +6,11 @@
|
||||
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 знаков).
|
||||
@@ -38,13 +41,44 @@ 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(texts):
|
||||
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)
|
||||
@@ -162,9 +196,24 @@ def cmd_index(a):
|
||||
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):
|
||||
@@ -216,16 +265,25 @@ def search(q, chat=None, since=None, n=12, min_sim=None):
|
||||
ranks[row["id"]] = ranks.get(row["id"], 0) + 1 / (60 + r)
|
||||
by_words.add(row["id"])
|
||||
qv = embed([q])[0]
|
||||
ids, mats = [], []
|
||||
for row in c.execute(f"SELECT c.id, c.emb FROM chunk c WHERE {w}", args): # 384 float32 на кусок — в памяти ок
|
||||
ids.append(row["id"])
|
||||
mats.append(row["emb"])
|
||||
if ids:
|
||||
m = np.frombuffer(b"".join(mats), dtype=np.float32).reshape(len(ids), -1)
|
||||
s = m @ qv
|
||||
for r, i in enumerate(np.argsort(-s)[:60]):
|
||||
sims[ids[i]] = float(s[i])
|
||||
ranks[ids[i]] = ranks.get(ids[i], 0) + 1 / (60 + r)
|
||||
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]
|
||||
@@ -321,6 +379,119 @@ REPORT_PROMPT = """Ты готовишь владельцу утреннюю с
|
||||
{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:
|
||||
@@ -340,25 +511,34 @@ def cmd_report(a):
|
||||
if a.dry:
|
||||
print(text)
|
||||
return
|
||||
OUTBOX.mkdir(parents=True, exist_ok=True)
|
||||
parts, cur = [], ""
|
||||
for para in text.split("\n"):
|
||||
if len(cur) + len(para) + 1 > TG_MAX and cur:
|
||||
parts.append(cur)
|
||||
cur = ""
|
||||
cur += para[:TG_MAX] + "\n"
|
||||
if cur.strip():
|
||||
parts.append(cur)
|
||||
old = os.umask(0o007)
|
||||
try:
|
||||
stamp = dt.datetime.now().strftime("%Y%m%d%H%M%S%f")
|
||||
for i, p in enumerate(parts):
|
||||
(OUTBOX / f"{stamp}-tg{i:02d}.txt").write_text(p.strip())
|
||||
finally:
|
||||
os.umask(old)
|
||||
n = queue_text(text, "tg")
|
||||
REPORT.mkdir(parents=True, exist_ok=True)
|
||||
(REPORT / "last-report.txt").write_text(text)
|
||||
print(f"сводка поставлена в очередь: {len(parts)} сообщ.")
|
||||
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():
|
||||
@@ -371,8 +551,14 @@ def main():
|
||||
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)
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -13,9 +13,11 @@ UMask=0027
|
||||
Nice=10
|
||||
# первый прогон долгий: Telegram троттлит чтение истории (FloodWait), скрипт ждёт сам
|
||||
TimeoutStartSec=infinity
|
||||
# только выбранные владельцем чаты (2026-10-18); не вступивший чат --only просто не находит — подтянется после вступления.
|
||||
# все кипрские группы и каналы (владелец 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 --only @CupRusHelpers @cylaw @badcyprus @cyprusdiy @cymedic -1001315261949
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user