Compare commits

13 Commits
Author SHA1 Message Date
astra ce23ac2824 rpi: отметить проверки принтера без подтверждения 2026-10-19 15:47:45 +03:00
gilfanovrandClaude Opus 5.5 5e6df382c8 fr1: DulyNotes — продолжение в комментарии через закреплённую копию поста, ретрай 429
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 21:28:33 +03:00
gilfanovrandClaude Opus 5.5 69ad9cf123 rpi: tg_bios.py — личные каналы участников; FloodWait ResolveUsername ~22 ч — размеры с t.me
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 21:22:09 +03:00
gilfanovrandClaude Opus 5.5 1a525e9073 rpi: tg_members.py — участники группы с аватарками (CY Fast Track Talks)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 20:18:07 +03:00
gilfanovrandClaude Opus 5.5 15a9e1d2de rpi: tg digest — вечерний рекап группы целиком (23:50 Кипр, Codex), CY Fast Track Talks
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 20:14:31 +03:00
gilfanovrandClaude Opus 5.5 dfe0fad728 rpi: tg-export — + @cyfasttrack (CY Fast Track Talks)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 20:08:36 +03:00
gilfanovrandClaude Opus 5.5 3bdb75d465 rpi: tg — кэш векторов (npz) и служба tg-embed: поиск ~6 с вместо ~40
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 20:01:45 +03:00
gilfanovrandClaude Opus 5.5 48c129615f rpi: открытый хвост — место на диске роутера (прогноз, алерт проверен)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 19:34:39 +03:00
gilfanovrandClaude Opus 5.5 e327b53496 TODO: пункты 8, 13, 15 — готово (владелец)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 19:31:11 +03:00
gilfanovrandClaude Opus 5.5 4da6260433 rpi: tg-export — все кипрские группы и каналы (кипр/cyprus в названии + явные @username), без личных и ботов
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 19:27:33 +03:00
gilfanovrandClaude Opus 5.5 f7adfb15c3 rpi: tg-export.service — вернуть комментарий
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 19:24:24 +03:00
gilfanovrandClaude Opus 5.5 a09106648b rpi: tg-export — 13 выбранных чатов (вступили в @cyvisaorg, @cylang, @cyprusit, @cyprusinet; @cyschools — заявка)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 19:24:10 +03:00
gilfanovrandClaude Opus 5.5 d13a05510c rpi: tg-export — + @cyprussport (Cyprus Sport)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 19:03:53 +03:00
11 changed files with 465 additions and 35 deletions
+3 -3
View File
@@ -39,13 +39,13 @@ _Собрано 2026-10-11._
- [ ] 5. Скилл для собственных проектов - [ ] 5. Скилл для собственных проектов
- [x] 6. Скилл для ведения собственных дел (2026-10-13: скилл `life`, `Дела насущные/`, см. `servers/rpi.md`) - [x] 6. Скилл для ведения собственных дел (2026-10-13: скилл `life`, `Дела насущные/`, см. `servers/rpi.md`)
- [ ] 7. Скрипты для работы с чатами — выжимки и слежение за интересующей информацией - [ ] 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 жены - [ ] 12. Опознать `.46` (`ea:08:6d:8c:59:56`, iOS без имени) и подтвердить, что `.13` — iPhone жены
- [ ] 13. Подключить календарь для Полины - [x] 13. Подключить календарь для Полины — ✅ готово (владелец, 2026-10-18)
- [x] 14. Подключить к почте Astra (индекс, классы, метки, ожидаемые письма) остальные ящики владельца — - [x] 14. Подключить к почте Astra (индекс, классы, метки, ожидаемые письма) остальные ящики владельца —
**ruzzyrullezz@gmail.com** подключён 2026-10-16 (см. `servers/rpi.md`, «Второй ящик»); другие ящики не нужны (владелец 2026-10-17). **ruzzyrullezz@gmail.com** подключён 2026-10-16 (см. `servers/rpi.md`, «Второй ящик»); другие ящики не нужны (владелец 2026-10-17).
Для Gmail — тот же OAuth-клиент проекта `astra`, второй токен; ⚠️ квота ~100 писем/мин на пользователя — первичная индексация долгая. Для 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/ Читалка books.ruzzy.dev: MCP уже есть (`POST /mcp`, инструменты search_books/read_chapter/get_toc/list_books/get_notes/
get_recaps, только чтение; сейчас им пользуется только ИИ самой читалки по внутреннему токену) — нужен внешний доступ: get_recaps, только чтение; сейчас им пользуется только ИИ самой читалки по внутреннему токену) — нужен внешний доступ:
Bearer-токены (`books_mcp.py add-token`) для Claude Code/Astra и OAuth 2.1 для custom connector в claude.ai. Bearer-токены (`books_mcp.py add-token`) для Claude Code/Astra и OAuth 2.1 для custom connector в claude.ai.
+1
View File
@@ -178,6 +178,7 @@ backend=systemd banaction=ufw
- Gmail MCP — без OAuth-кредов (gmail-инструменты не работают, на бот не влияет). - Gmail MCP — без OAuth-кредов (gmail-инструменты не работают, на бот не влияет).
- **MCP `books` с 2026-10-18** (книги читалки, только чтение): `claude mcp add --scope user --transport http books https://books.ruzzy.dev/mcp` с Bearer-токеном `bot:iluha_rabobot` (токен — только в `/root/.claude.json` тома и хешем в `books.db`). В `/root/CLAUDE.md` бота дописан раздел «Книги владельца» (без бэкапа: `cp` в `/root` не прошёл — нет прав; раздел — в конце файла, удаляется целиком): ответы по книгам, публикация в Telegram-канал только по команде владельца, без дословных глав. Контейнер перезапущен (ушёл в новую сессию: прежняя была >5 МБ и заархивирована entrypoint-ом). ⚠️ Текст книг — чужие PDF, теперь он попадает к агенту с SSH-ключами к fr1/gitea и `--dangerously-skip-permissions`: защита от внедрённых инструкций — только правило в CLAUDE.md. Канал для публикаций бот подключает сам по команде владельца «подключи канал @имя» (раздел «Подключение канала» в его CLAUDE.md): `getChat`/`getChatMember` по Bot API → id в `groups` в `/root/.claude/channels/telegram/access.json` (без этого `reply` в канал отклоняется) → строка в «Подключённые каналы» в конце CLAUDE.md. Подключён канал **DulyNotes** (приватный, без @username, id `-1004305290483`, бот — админ с правом публикации) 2026-10-18; бэкап access.json до правки — `/tmp/access.json.bak` в контейнере. ⚠️ У приватного канала `getChat` по имени не работает — id узнавали `getUpdates` при приостановленном (`kill -STOP`) плагине, без `offset`, потом вернули `allowed_updates=[]` (параметр `getUpdates` запоминается Telegram и действует на плагин). - **MCP `books` с 2026-10-18** (книги читалки, только чтение): `claude mcp add --scope user --transport http books https://books.ruzzy.dev/mcp` с Bearer-токеном `bot:iluha_rabobot` (токен — только в `/root/.claude.json` тома и хешем в `books.db`). В `/root/CLAUDE.md` бота дописан раздел «Книги владельца» (без бэкапа: `cp` в `/root` не прошёл — нет прав; раздел — в конце файла, удаляется целиком): ответы по книгам, публикация в Telegram-канал только по команде владельца, без дословных глав. Контейнер перезапущен (ушёл в новую сессию: прежняя была >5 МБ и заархивирована entrypoint-ом). ⚠️ Текст книг — чужие PDF, теперь он попадает к агенту с SSH-ключами к fr1/gitea и `--dangerously-skip-permissions`: защита от внедрённых инструкций — только правило в CLAUDE.md. Канал для публикаций бот подключает сам по команде владельца «подключи канал @имя» (раздел «Подключение канала» в его CLAUDE.md): `getChat`/`getChatMember` по Bot API → id в `groups` в `/root/.claude/channels/telegram/access.json` (без этого `reply` в канал отклоняется) → строка в «Подключённые каналы» в конце CLAUDE.md. Подключён канал **DulyNotes** (приватный, без @username, id `-1004305290483`, бот — админ с правом публикации) 2026-10-18; бэкап access.json до правки — `/tmp/access.json.bak` в контейнере. ⚠️ У приватного канала `getChat` по имени не работает — id узнавали `getUpdates` при приостановленном (`kill -STOP`) плагине, без `offset`, потом вернули `allowed_updates=[]` (параметр `getUpdates` запоминается Telegram и действует на плагин).
- **DulyNotes: разбор книг по расписанию** (с 2026-10-18; с того же дня — **очередь книг**: `books/<slug>.json` — главы, прогресс, `posted`, настройки per_slot/slots/topic/lang; `queue.json` — `{active, queue}`; ставит бот по разговору с владельцем через `pubctl.mjs` (status | add <book_id> [--dry] [--skip] | set | remove | activate); закончилась книга — со следующего слота следующая; неподготовленная читалкой глава — слот пропускается, владельцу уведомление; тексты заметок — `log/<slug>/ch<idx>.html`; прежний `learning-go.json` перенесён, бэкап `learning-go.json.bak-queue`). `/root/publisher/` в томе (создан от root: `/root` в контейнере — root:claude 755, бот сам туда не пишет): `publish.mjs` — в 9:00 и 21:00 по Никосии по 2 главы «Learning Go» (book_id=2, 16 глав → 4 дня) в канал `-1004305290483`, с 2026-10-18 каждая отдельным сообщением как самостоятельная техническая заметка о Go: заголовок по теме, без упоминания книги, автора и глав, связный рассказ с переходом от прошлой заметки (её текст — `log/ch<idx>.html`), 2800–3500 символов; длиннее 3800 — второй проход «сократи», с 2026-10-18 вечера статьи 6000–8500 символов с 5–7 примерами кода (`<pre>` → `<pre><code class="language-go">` — подсветка в Telegram; режется по абзацам, не разрывая код): начало — постом, продолжение — **в комментариях** (группа обсуждений DulyNotesChat `-1004373354142`, бот — админ). Bot API не отдаёт id копии поста в группе, а апдейты держит плагин, поэтому копию ищем перебором: проба-сообщение даёт верхнюю границу id, затем ответ на кандидатов с проверкой `reply_to_message.is_automatic_forward` и `forward_origin.message_id` == id поста, промахи сразу удаляются; копия появляется с задержкой — до 5 попыток с паузой. Не вышло — продолжение ответом в канале. ⚠️ Если в группе удалить копию поста, комментарии под ним пропадают. Ручные режимы: `--fix-comments` (перенести продолжение из канала в комментарии), `--edit-last`, `--repost`; id сообщений — `posted` в `learning-go.json`; `claude -p` запускается из пустого `/tmp/publisher-run`, иначе подхватывает `/root/CLAUDE.md` бота; очередь и сделанные слоты — `learning-go.json`, лог и тексты глав — `log/`. Пост пишет `claude -p` (sonnet, по той же подписке) в **изолированном конфиге** `.claude-config` (только `.credentials.json` — симлинк на основной, пустые settings): без плагинов/хуков/встроенных инструментов, только MCP `books` (`books-mcp.json`, 600, токен `bot:iluha_rabobot`); ⚠️ `MCP_CONNECTION_NONBLOCKING=false` обязателен — иначе MCP «pending» и модель пишет вызов инструмента текстом. ⚠️ Основной конфиг для `claude -p` не годится: поднимется второй telegram-плагин, а он при старте убивает чужой опрос (`server.ts`, «Kill any stale holder»). Отправка — Bot API напрямую; HTML чистится от «<» не по делу, при отказе Telegram — текстом; ошибки — владельцу в личку. Ручные режимы: `--now`, `--preview`. - **DulyNotes: разбор книг по расписанию** (с 2026-10-18; с того же дня — **очередь книг**: `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`), админство в группе не нужно. - **Комментарии 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`. - **`/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
View File
@@ -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. Итог и обновление метрики успеха ещё ожидаются, алерт вручную не сбрасывался. - 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) ## 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, не переносилась (решение владельца). - Проект `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`. - Отдельный системный пользователь `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. - Мониторинг — `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`), как почта. - Доступ: тексты — только для владельца; Оле и Поле закрыто (правило в `codex/AGENTS.md` и скилл `tg`, astra-bot `27bf8e6`), как почта.
## Читалка books → Obsidian (с 2026-10-18) ## Читалка 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). - Заметки и рекапы из 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-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`, результат готовности приходит владельцу.
+20 -1
View File
@@ -11,7 +11,7 @@
| Ключи приложения (`TG_API_ID`/`TG_API_HASH`, my.telegram.org) | `/etc/tg-export/env` — root:tgexport 640, в git нет | | Ключи приложения (`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 | | Сессия (= **полный доступ к аккаунту**) | `/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 | | Выгрузка | `/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`) | | Таймер | `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 ч) | | Мониторинг | `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 с при Сравнение на 8 тыс. сообщений групп 2026-10-18: potion 57 с, `paraphrase-multilingual-MiniLM-L12-v2` 915 с при
сопоставимом качестве поиска (MiniLM к тому же видит только ~128 токенов куска). e5-large/jina-v3 на Pi 4 — сопоставимом качестве поиска (MiniLM к тому же видит только ~128 токенов куска). e5-large/jina-v3 на Pi 4 —
дни на историю. Сменить — `TG_EMB_MODEL` + переиндексация (удалить `index.db`). дни на историю. Сменить — `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 - `tg search ЗАПРОС [--chat] [--since 90d] [-n]` — гибрид BM25 + косинус (RRF), куски со ссылками t.me
(`t.me/<username>/<id>` или `t.me/c/<id>/<msg>`). ⚠️ Одна формулировка часто промахивается (`t.me/<username>/<id>` или `t.me/c/<id>/<msg>`). ⚠️ Одна формулировка часто промахивается
(«посоветуйте спортзал» не нашло «фитнес в отелях») — Astra по навыку `tg` переформулирует 2–4 раза. («посоветуйте спортзал» не нашло «фитнес в отелях») — Astra по навыку `tg` переформулирует 2–4 раза.
@@ -54,3 +58,18 @@ sudo systemctl enable --now tg-export.timer
не лимит Claude читалки) → очередь `/var/lib/astra-google/outbox/` → `gcal remind` шлёт владельцу. не лимит Claude читалки) → очередь `/var/lib/astra-google/outbox/` → `gcal remind` шлёт владельцу.
⚠️ Очередь шлёт одним `sendMessage` без разбиения: сводка режется на части ≤ 3800 знаков (иначе файл > 4096 ⚠️ Очередь шлёт одним `sendMessage` без разбиения: сводка режется на части ≤ 3800 знаков (иначе файл > 4096
застрял бы и заблокировал очередь). Тем нет или индекса нет — ничего не шлёт. `tg report --dry` — посмотреть. застрял бы и заблокировал очередь). Тем нет или индекса нет — ничего не шлёт. `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.
+106
View File
@@ -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]))
+76
View File
@@ -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]))
+214 -28
View File
@@ -6,8 +6,11 @@
tg topic add "ТЕМА" [--query "что искать"] | list | del НОМЕР tg topic add "ТЕМА" [--query "что искать"] | list | del НОМЕР
темы ежедневной сводки (query — поисковая формулировка, по умолчанию — сама тема) темы ежедневной сводки (query — поисковая формулировка, по умолчанию — сама тема)
tg report [--hours 24] [--dry] сводка по темам за период → владельцу в Telegram (--dry — только напечатать) tg report [--hours 24] [--dry] сводка по темам за период → владельцу в Telegram (--dry — только напечатать)
tg digest add @ЧАТ | list | del @ЧАТ чаты для ежедневного рекапа целиком (всё за день, а не по темам)
tg digest run [--hours N] [--dry] рекап этих чатов с прошлого рекапа → владельцу в Telegram (таймер 23:50)
tg stats сколько чатов/сообщений/кусков, когда обновлялось tg stats сколько чатов/сообщений/кусков, когда обновлялось
tg index (сервис tg-export, юзер tgexport) — дописать в индекс новые сообщения выгрузки tg index (сервис tg-export, юзер tgexport) — дописать в индекс новые сообщения выгрузки
tg serve (служба tg-embed, юзер astra) — держать модель в памяти, векторы запросов по unix-сокету
Индекс — /mnt/backup/tg/index.db (SQLite: FTS5 + эмбеддинги potion-multilingual-128M), пишет tgexport, Индекс — /mnt/backup/tg/index.db (SQLite: FTS5 + эмбеддинги potion-multilingual-128M), пишет tgexport,
читают все из группы astra. Кусок = подряд идущие сообщения одного чата с паузами < 20 мин (до ~800 знаков). читают все из группы astra. Кусок = подряд идущие сообщения одного чата с паузами < 20 мин (до ~800 знаков).
@@ -38,13 +41,44 @@ TZ = ZoneInfo("Asia/Nicosia")
GAP = 20 * 60 # пауза, после которой начинается новый кусок GAP = 20 * 60 # пауза, после которой начинается новый кусок
CHUNK_CHARS = int(os.environ.get("TG_CHUNK_CHARS", "800")) CHUNK_CHARS = int(os.environ.get("TG_CHUNK_CHARS", "800"))
TG_MAX = 3800 # одно сообщение Telegram (лимит 4096) — сводку режем на части TG_MAX = 3800 # одно сообщение Telegram (лимит 4096) — сводку режем на части
# векторы всех кусков одним файлом рядом с индексом: чтение 85 тыс. BLOB из SQLite на HDD — ~40 с на запрос,
# а np.load(mmap) — доли секунды; пересобирается после каждой индексации
CACHE = DB.with_suffix(".emb.npz")
_emb = None _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 global _emb
import numpy as np 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: if _emb is None:
from fastembed import TextEmbedding from fastembed import TextEmbedding
_emb = TextEmbedding(EMB_MODEL, cache_dir=MODELS, threads=4) _emb = TextEmbedding(EMB_MODEL, cache_dir=MODELS, threads=4)
@@ -162,9 +196,24 @@ def cmd_index(a):
total += len(new) total += len(new)
c.execute("INSERT OR REPLACE INTO meta VALUES ('indexed', ?)", (dt.datetime.now(TZ).isoformat(timespec="seconds"),)) c.execute("INSERT OR REPLACE INTO meta VALUES ('indexed', ?)", (dt.datetime.now(TZ).isoformat(timespec="seconds"),))
c.commit() c.commit()
if total or not CACHE.exists():
build_cache(c)
print(f"проиндексировано новых сообщений: {total}") 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): 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) ranks[row["id"]] = ranks.get(row["id"], 0) + 1 / (60 + r)
by_words.add(row["id"]) by_words.add(row["id"])
qv = embed([q])[0] qv = embed([q])[0]
ids, mats = [], [] if CACHE.exists():
for row in c.execute(f"SELECT c.id, c.emb FROM chunk c WHERE {w}", args): # 384 float32 на кусок — в памяти ок z = np.load(CACHE)
ids.append(row["id"]) ids, emb, mask = z["ids"], z["emb"], np.ones(len(z["ids"]), dtype=bool)
mats.append(row["emb"]) if since:
if ids: mask &= z["ts"] >= since
m = np.frombuffer(b"".join(mats), dtype=np.float32).reshape(len(ids), -1) if chat:
s = m @ qv cids = [r[0] for r in c.execute("SELECT id FROM chat WHERE title LIKE ? OR username LIKE ?", (f"%{chat}%", f"%{chat}%"))]
for r, i in enumerate(np.argsort(-s)[:60]): mask &= np.isin(z["chat"], cids)
sims[ids[i]] = float(s[i]) ids, emb = ids[mask], emb[mask]
ranks[ids[i]] = ranks.get(ids[i], 0) + 1 / (60 + r) 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) top = sorted(ranks, key=ranks.get, reverse=True)
if min_sim is not None: # для сводки: найденное словами — всегда, по смыслу — только достаточно близкое 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 = [i for i in top if i in by_words or sims.get(i, 0) >= min_sim]
@@ -321,6 +379,119 @@ REPORT_PROMPT = """Ты готовишь владельцу утреннюю с
{data}""" {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): def cmd_report(a):
t = topics() t = topics()
if not t: if not t:
@@ -340,25 +511,34 @@ def cmd_report(a):
if a.dry: if a.dry:
print(text) print(text)
return return
OUTBOX.mkdir(parents=True, exist_ok=True) n = queue_text(text, "tg")
parts, cur = [], ""
for para in text.split("\n"):
if len(cur) + len(para) + 1 > TG_MAX and cur:
parts.append(cur)
cur = ""
cur += para[:TG_MAX] + "\n"
if cur.strip():
parts.append(cur)
old = os.umask(0o007)
try:
stamp = dt.datetime.now().strftime("%Y%m%d%H%M%S%f")
for i, p in enumerate(parts):
(OUTBOX / f"{stamp}-tg{i:02d}.txt").write_text(p.strip())
finally:
os.umask(old)
REPORT.mkdir(parents=True, exist_ok=True) REPORT.mkdir(parents=True, exist_ok=True)
(REPORT / "last-report.txt").write_text(text) (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(): def main():
@@ -371,8 +551,14 @@ def main():
r = ts.add_parser("del"); r.add_argument("num") r = ts.add_parser("del"); r.add_argument("num")
ts.add_parser("list") 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("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") sub.add_parser("stats")
s = sub.add_parser("index"); s.add_argument("--quiet", action="store_true") s = sub.add_parser("index"); s.add_argument("--quiet", action="store_true")
sub.add_parser("serve")
a = p.parse_args() a = p.parse_args()
globals()["cmd_" + a.cmd](a) 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 Nice=10
# первый прогон долгий: Telegram троттлит чтение истории (FloodWait), скрипт ждёт сам # первый прогон долгий: Telegram троттлит чтение истории (FloodWait), скрипт ждёт сам
TimeoutStartSec=infinity 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). # -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) # индекс для поиска и сводки: новые сообщения → куски + эмбеддинги (/mnt/backup/tg/index.db)
ExecStartPost=/opt/tg-export/venv/bin/python /opt/tg-export/tgrag.py index --quiet ExecStartPost=/opt/tg-export/venv/bin/python /opt/tg-export/tgrag.py index --quiet
ExecStopPost=+/usr/local/bin/backup-metrics backup_tg_export ExecStopPost=+/usr/local/bin/backup-metrics backup_tg_export