diff --git a/.claude/commands/daily.md b/.claude/commands/daily.md new file mode 100644 index 0000000..bd2692c --- /dev/null +++ b/.claude/commands/daily.md @@ -0,0 +1,29 @@ +Прочитай последние дейлики из Obsidian vault и дай краткую выжимку. + +Путь к дейликам: `C:/Program Files/Obsidian/obsVaultPC/GameChanger/Daily/` + +## Инструкции + +1. Найди последние 5 файлов по дате в имени (формат YYYY-MM-DD.md) +2. Прочитай их через Read tool +3. Дай краткую выжимку: + - Что на уме / основные темы + - Эмоциональный фон (без психологизирования — просто факт) + - Если есть задачи или намерения — выдели отдельно +4. Если пользователь указал аргумент (число) — читай столько дейликов. Пример: `/daily 10` +5. Не давай непрошеных советов. Просто покажи что видишь. + +## Формат вывода + +``` +## Последние дейлики (N шт) + +### YYYY-MM-DD +[2-3 предложения суть] + +### YYYY-MM-DD +[2-3 предложения суть] + +--- +**Общий фон:** [одно предложение] +``` diff --git a/.claude/commands/handoff.md b/.claude/commands/handoff.md index ad71b2a..fb11896 100644 --- a/.claude/commands/handoff.md +++ b/.claude/commands/handoff.md @@ -9,5 +9,12 @@ - Обнови "Прогресс" (чеклист) - Запиши конкретный "Следующий шаг" - Если есть нерешённые проблемы — добавь в "Известные проблемы" -4. Выведи: что записано, следующий шаг -5. Скажи пользователю что можно делать /clear +4. Сгенерируй пост для Telegram: + - Запусти `uv run python tools/pipeline/main.py --dry-run` (из директории tools/pipeline) + - Покажи пользователю сгенерированный пост + - Спроси: "Отправить в Telegram? (да / нет / правки)" + - Если "да" — запусти `uv run python tools/pipeline/main.py` (без --dry-run) + - Если "правки" — пользователь даёт фидбек, перегенерируй или отредактируй вручную + - Если "нет" — пропустить +5. Выведи: что записано, следующий шаг +6. Скажи пользователю что можно делать /clear diff --git a/.claude/commands/screenshot.md b/.claude/commands/screenshot.md index 0243021..afe7b06 100644 --- a/.claude/commands/screenshot.md +++ b/.claude/commands/screenshot.md @@ -1,5 +1,5 @@ Пользователь сделал скриншот (Win+Shift+S). Сохрани и прочитай. -1. Выполни: `powershell -ExecutionPolicy Bypass -File "D:/code/2026/claudeCopy/tools/grab-clipboard.ps1"` -2. Прочитай полученный файл через Read tool +1. Выполни: `powershell.exe -NoProfile -Command "Add-Type -AssemblyName System.Windows.Forms; if ([System.Windows.Forms.Clipboard]::ContainsImage()) { [System.Windows.Forms.Clipboard]::GetImage().Save('C:/tmp/clipboard_screenshot.png'); Write-Output 'saved' } else { Write-Output 'no image in clipboard' }"` +2. Прочитай `C:/tmp/clipboard_screenshot.png` через Read tool 3. Опиши что на скриншоте и спроси что с ним делать diff --git a/.claude/commands/tg-digest.md b/.claude/commands/tg-digest.md new file mode 100644 index 0000000..c6f4f13 --- /dev/null +++ b/.claude/commands/tg-digest.md @@ -0,0 +1,53 @@ +Глубокий дайджест Telegram-группы через Gemini. $ARGUMENTS — ссылка на группу или username. + +## Инструкции + +1. Загрузи env из `D:/code/2026/2/cortex/.env` (через dotenv или export) + +2. Забери последние 1500 сообщений из группы через Telethon: + - Используй session: `data/tg-groups/cortex_userbot` + - Креды: TG_API_ID и TG_API_HASH из .env + - Извлеки: текст, URLs, forwards, replies, reactions, sender name, message id, date + +3. Подготовь данные для LLM: + - Формат: `[date] #id sender: [REPOST from X] [reply to #Y] text | URLs: url1, url2 [reactions:N]` + - Сохрани в temp файл + +4. Отправь в Gemini (НЕ анализируй сам — экономия токенов Claude): + ```python + from google import genai + import os + # GOOGLE_API_KEY из .env + client = genai.Client() + response = client.models.generate_content(model="gemini-3-flash-preview", contents=prompt + messages) + ``` + +5. Промпт для Gemini: + ``` + Глубокий аналитический дайджест Telegram-группы. + + Структура: + 1. ГОРЯЧИЕ ТЕМЫ И ДИСКУССИИ — что обсуждали, позиции участников, консенсус + 2. ИНСТРУМЕНТЫ И ТЕХНОЛОГИИ — что упоминается, что хвалят/ругают, советы + 3. ССЫЛКИ И РЕСУРСЫ — каждый URL: что это, зачем. Репосты отдельно + 4. КЕЙСЫ И ПРОЕКТЫ — кто что делает, результаты, грабли + 5. ИНСАЙТЫ И ВЫВОДЫ — тренды, практические советы, что попробовать + + Правила: + - Русский, технические термины на английском + - Конкретика > абстракция. Имена, числа, инструменты + - Не пропускай ссылки + - Следи за ветками (reply_to) и репостами + - Markdown, 2000-4000 слов + ``` + +6. Результат: + - Сохрани в `data/tg-groups/{group_name}_digest.md` + - Отправь .md файлом в Telegram канал (TELEGRAM_CHAT_ID из .env) + - Покажи краткое саммари пользователю + +## Параметры + +- Без аргументов: покажи список доступных групп из `tools/tg-monitor/config.py` +- С аргументом: `@username` или `https://t.me/groupname` или просто `groupname` +- Доп. флаги в аргументах: `--limit 500` (по умолчанию 1500), `--no-send` (не отправлять в канал) diff --git a/.gitignore b/.gitignore index fe5f745..5cb5531 100644 --- a/.gitignore +++ b/.gitignore @@ -58,3 +58,10 @@ screenshots/ # Claude Code local settings (permissions, not for sharing) .claude/settings.local.json tg/ + +# TG Monitor data (messages JSON + Telethon session) +data/tg-groups/ +*.session + +# TG Bridge history +tools/tg-bridge/history.json diff --git a/CLAUDE.md b/CLAUDE.md index d4be118..5cc6589 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,7 +16,7 @@ ## 3. Workflow - Перед значительными изменениями — план. Большие задачи → мелкие шаги. -- Git First: коммить после каждой рабочей фичи. Не коммить в main напрямую. +- Git: коммить ТОЛЬКО по запросу (`/quick-commit`, "закоммить") или при `/handoff`. Не коммить в main напрямую. - Не запускай `npm run dev` / `python -m ...` автоматически. - Quality Gates: Build → Types → Lint → Tests (80%+) → Security → Diff. - Conventional Commits: `(): `. Детали: @docs/git-flow.md @@ -25,7 +25,7 @@ | Момент | Действие | |--------|----------| -| **Старт сессии** | Прочитать DEV_CONTEXT.md и PROJECT_CONTEXT.md | +| **Старт сессии** | Прочитать DEV_CONTEXT.md, PROJECT_CONTEXT.md и последний дейлик из Obsidian (`C:/Program Files/Obsidian/obsVaultPC/GameChanger/Daily/`) | | **После кода** | Проверить build/тесты | | **Перед PR** | `/verify` | | **Финиш сессии** | `/handoff` | diff --git a/DEV_CONTEXT.md b/DEV_CONTEXT.md index 81172e4..32d6519 100644 --- a/DEV_CONTEXT.md +++ b/DEV_CONTEXT.md @@ -1,16 +1,152 @@ # Development Context Log ## Последнее обновление -- Дата: 2026-02-28 +- Дата: 2026-03-04 ## Текущий статус -- Этап: Контент-пайплайн запущен и работает в продакшне. -- Последнее действие: tools/pipeline написан (Gemini 3 Flash), задеплоен через GitHub Actions, протестирован — посты летят в Telegram. -- Текущий фокус: пайплайн работает автоматически (merge DEV_CONTEXT → пост). VM пока не задействована для пайплайна. -- Следующий шаг: получить экспорт Telegram канала (заметки + аудио) → анализ → обогащение контекста. +- Этап: PharmOrder VPS — история приходов + Созвездие матрица в продакшне. +- Последнее действие: сессия 20 — история приходов (ReeTov.DBF), маркеры Созвездие (О/Р), прайс-чекер (розничные цены). +- Текущий фокус: прайс-чекер по DataMatrix (нужен ReeTov.DBF с рабочего компа, там розничные цены). +- Следующий шаг: принести ReeTov.DBF с рабочего ПК → добавить BL_ROSN_PR в order_history.db → endpoint "цена по GTIN". ## История изменений +### 2026-03-04 — История приходов + Созвездие матрица + прайс-чекер R&D (сессия 20) +- Что сделано: + - **История приходов (ReeTov.DBF)**: sozvhist.dbf оказался бонусной программой "Созвездие", а НЕ историей заказов. Найден ReeTov.DBF (85,805 записей, 7,747 EAN, 13 поставщиков) — реестр приходов товаров. Перестроен order_history.db, обновлены SQL-запросы в server.py (ean вместо ean13, nakl_date вместо datezak, pr_w_nds вместо price). Бейджи в UI: "123x (72%)" рядом с поставщиками. + - **Созвездие (МС "Созвездие")**: исследован маркетинговый союз аптек при ПУЛЬС. Два типа матриц: MANDATORY_MATRIX (О — обязательная) и RECOMMENDED_GOODS (Р — рекомендованная). Текущий квартал 1кв2026: 18,073 EAN (5,614 обязательных + 12,459 рекомендованных). Создан sozvezdie.db (product.dbf + product_post.dbf + workt_5160.dbf → SQLite с таблицами matrix_products и matrix_suppliers). API: `/api/sozvezdie` (по EAN → тип матрицы + рек.цена + список поставщиков), `/api/sozvezdie-batch` (batch-проверка для поиска). UI: маркеры О/Р в поиске, в заголовке товара, на строках поставщиков + инфо-баннер "Товар входит в маркетинговый ассортимент Созвездие (Обязательная матрица, рек.цена: 68₽)". + - **Прайс-чекер R&D**: розничные цены хранятся в ReeTov.DBF поле BL_ROSN_PR (закупка × наценка из gradeRascen.DBF). Формула: до 300₽→25%, 300-600₽→24%, 600+₽→23%. НО на маминой копии BL_ROSN_PR=0 — расценка делается на рабочем ПК. Нужен ReeTov.DBF с рабочего компа. DataMatrix парсинг работает: GTIN→EAN→поиск в базе (протестировано на 3 кодах: Колдакт бронхо, Цистон, Питавастор). + - **sync_standalone.py обновлён**: добавлены `_convert_reetov()`, `sync_order_history()`, `_convert_sozvezdie()`, `sync_sozvezdie()`. Main loop: прайсы (60с) + заявки (5с) + история (5мин) + матрица (10мин). sync.bat: добавлен `dbfread` в зависимости. + - **sklit_sync.zip** пересобран (12 KB) на рабочем столе — готов для мамы. +- Файлы: server.py (supplier-history SQL fix, sozvezdie endpoints, sozvezdie-batch), index.html (supplier hist badges, sozvezdie badges О/Р в поиске/header/offers, info banner), order_history.db (rebuilt from ReeTov), sozvezdie.db (new), sync_standalone.py (reetov + sozvezdie sync) +- Данные: ReeTov.DBF=85,805 приходов (март 2025—март 2026), product.dbf=36,511 товаров матрицы, 120,608 связей товар-поставщик + +### 2026-03-04 — Scan items fix + EAN aliases + параллельные сессии (сессия 19) +- Что сделано: + - **Баг scan items**: при перезапуске sync.bat отсканированные коды исчезали через секунду. Причина: sync.bat открывал браузер без `?key=` → 401, плюс race condition — пустой localStorage мог перезаписать серверные данные. + - **Фикс sync.bat**: URL теперь включает `?key=...` для автоматической авторизации. + - **Фикс index.html (VPS)**: добавлен флаг `_scanItemsServerLoaded` — пустой массив НЕ перезаписывает сервер до первой успешной загрузки. Убрано условие `d.items.length` при инит-загрузке. + - **EAN alias**: Энам (4810703128026, белорусский перепак) привязан к Энам 20мг (оригинальный EAN 8901148245525) — 13 предложений от поставщиков. + - **Диагностика**: 3 неизвестных EAN (4610166050113, 5060391651965, 4810703128026). Первые два — реально отсутствуют у поставщиков. Третий (Энам) — исправлен. + - **Параллельные сессии**: работа в двух окнах Claude Code одновременно для ускорения итераций. + - **sklit_sync.zip** пересобран (11 KB, без .db файлов) для отправки маме. +- Файлы: index.html (VPS, scan items guard), sync.bat (auth URL), sklit_cache.db (VPS, EAN alias) +- Архитектура: для полноценной привязки альтернативных EAN нужна таблица `ean_aliases(ean → id_name)` — пока ручные INSERT в продакшн DB. + + +### 2026-03-03 — Delta sync + shared scan items (сессия 18) +- Что сделано: + - **Delta sync**: полная переработка загрузки прайсов. Вместо 89MB полного дампа — только изменения (set-based diff). Первый запуск сохраняет snapshot, последующие шлют дельту (upserts + deletes). Payload gzip-ится при >512KB. + - **Удалён paramiko/SFTP**: загрузка прайсов теперь HTTP-only (`POST /api/sync/prices-delta`). Зависимость `paramiko` убрана из sync.bat. + - **`--upload` флаг**: принудительная полная загрузка БД, если нужен ресет. + - **Мультикомп синк**: протестировано на домашнем ПК (184926 продуктов) + мамин ноутбук (184601 продуктов). Дельты работают — "Без изменений" при повторном запуске. + - **First-run логика**: новый ПК только сохраняет snapshot, НЕ загружает полный прайс (VPS уже имеет данные от другого ПК). + - **Shared scan items**: scanItems переехали из localStorage в server-side storage. `GET/POST /api/scan-items` + polling каждые 3 сек. Коды видны на всех компах в реальном времени. + - **sync.bat фикс**: URL теперь включает API key (`?key=...`) для автоматической авторизации. + - **Баг дубликатов**: SQL JOIN-based delta давал потери (~18K продуктов). Исправлено на set-based comparison (Python sets полных кортежей). +- Файлы: server.py (delta endpoint + scan-items API), index.html (server sync вместо localStorage), sync_standalone.py (delta sync), sync.bat (убран paramiko) +- Архитектура: любой ПК может загрузить дельту прайсов, VPS — single source of truth, scan items общие для всех браузеров. + +### 2026-03-02 — Standalone sync client + Context Mode MCP (сессия 17) +- Что сделано: + - **Context Mode MCP установлен**: `claude mcp add context-mode -- npx -y context-mode`. Сжимает выход тулов на 98% (56KB→299B), сессии живут значительно дольше. Работает через batch_execute, execute, index, search, fetch_and_index. + - **Диагностика**: sync_standalone.py (в sklit_sync.zip) не загружал прайсы на VPS — только забирал заявки. Поэтому счётчик не обновлялся. + - **Фикс sync_standalone.py**: дописан полный двусторонний синк (~420 строк, zero deps на проект): + - `sync_prices()`: watch pr_all.dbf mtime → convert() DBF→SQLite+FTS → upload на VPS + - `pull_and_write()`: poll VPS → zayava.DBF (было и раньше) + - Встроены: `convert()`, `_build_supplier_map()`, `_read_apteks()`, supplier map, apteks meta + - Архив `sklit_sync.zip` пересобран на рабочем столе + - Протестировано на домашнем ПК — прайсы загрузились на VPS + - **VPS.md** обновлён: два варианта запуска, настройка на чистом ПК, таблица диагностики + - План автозагрузки: `.pyw` + `shell:startup` или Task Scheduler +- Файлы: sync_standalone.py (обновлён), sync.bat (обновлён), VPS.md (обновлён) + +### 2026-03-02 — PharmOrder VPS полировка (сессия 16) +- Что сделано: + - **Sync-статус в header**: `GET /api/sync/status` — зелёная/жёлтая/красная точка + возраст базы, pending exports count + - Sync-индикатор поллит каждые 30 сек, VPS only (в local mode скрыт) + - Кнопка "Обновить прайсы" скрывается в VPS mode (sync_client обновляет) + - **Поиск фикс**: contains-match вместо starts-with only. "цитрал" теперь находит и "Цитралгин" и "Гель цитралгин флебогель". Starts-with идут первыми в сортировке. + - **Фильтр нулевых остатков**: товары без остатков у всех поставщиков не показываются в поиске (EAN-поиск не фильтруется) + - **Batch auto_distribute()**: одно подключение к БД, batch SQL (IN (...)) вместо per-item lookup. 50 позиций: 3мс вместо ~10с. + - **`POST /api/cart/batch`**: добавление всех позиций автозаказа одним запросом вместо sequential await per item (~1 мин → мгновенно) + - **UI cleanup**: убран buildTag ("build 2026-02-12-7"), убрана статистика ("189k / 55 пост."), Cloud OK + sync indicator справа + - **Серая полоса сбоку**: border/shadow на закрытых панелях убраны (только на open) + - Прайсы обновлены и залиты на VPS (189k продуктов, 86.5MB) + - VPS.md — документация для работы на рабочем ПК (доступы, архитектура, команды) +- Файлы: server.py, db.py, static/index.html, VPS.md (новый) + +### 2026-03-02 — PharmOrder на VPS (сессия 15) +- Что сделано: + - **PharmOrder задеплоен на VPS** (194.87.140.204:8000) — systemd service, auth middleware (API key) + - Auth: `X-API-Key` header / cookie / `?key=` query param. `/health` без auth. Cookie ставится на 30 дней. + - **Sync архитектура**: VPS (PharmOrder) ← sync → локальный ПК (sync_client.py) + - `POST /api/sync/upload-db` — sync-client заливает sklit_cache.db (90MB, 190k продуктов) + - `GET /api/sync/pending-exports` + `POST /api/sync/confirm-export` — очередь экспортов + - `prepare_export()` в db.py — строит записи для zayava.DBF без прямой записи (VPS mode) + - `export_to_sklit()` рефакторнут: общая `_build_export_records()` + прямая запись только в local mode + - Supplier map + apteks info сохраняются в SQLite при convert() — VPS читает из DB, не из DBF + - `_build_supplier_map()` и `_read_apteks()` — SQLite fallback когда DBF файлов нет + - `needs_update()` — VPS mode: не пытается конвертить без DBF + - **sync_client.py** (~180 строк): watch pr_all.dbf → convert → upload DB; poll exports → write zayava.DBF → confirm + - **Batch lookup**: `POST /api/lookup-batch` — все EAN за один запрос. **16x ускорение** (400ms vs 6.3s на 20 кодов) + - Frontend `processScanQueue()` переписан: один batch запрос вместо sequential await per item + - **UI cloud history**: consumed выгрузки серые (opacity 0.45), badge на кнопке "Облако" с числом новых + - E2E тест пройден: заказ на VPS → sync-client забрал → zayava.DBF с правильными ID_PRICE/ID_POST/ID_A/ID_GRP + - Локальный режим (run.bat) не затронут — мама работает как раньше +- API key VPS: `464AFZ-j5lluujCAgO4JrKkLD8twd_U5Hys5yGlTRck` +- URL: `http://194.87.140.204:8000/?key=464AFZ-j5lluujCAgO4JrKkLD8twd_U5Hys5yGlTRck` +- Файлы: server.py, db.py, sync_client.py (новый), sync_client.bat (новый), .env.sync (новый), .env.example (новый) + +### 2026-03-02 — PharmOrder планирование + TG уведомления (сессия 14) +- Что сделано: + - Удалены неиспользуемые bash-скрипты (init-project.sh, setup-vm.sh) + - Починен /screenshot — теперь читает из буфера обмена напрямую (PowerShell) + - Проверена VM cortex-vm: SSH только через `gcloud compute ssh`, daily digest таймер активен (06:00 MSK) + - Heartbeat запущен: найден Context Mode MCP (98% сжатие контекста, 524 HN points) — записан в backlog + - TG уведомления для мамы: relay_server.py на VPS дополнен notify_telegram(), мама (Luda, chat_id 7255623391) получает "Новая заявка: X позиций" при каждом POST /api/scans + - paramiko установлен на Windows для SSH к VPS + - Исследован PharmOrder: FastAPI + SQLite + DBF, экспорт пишет в C:\SKLIT\zayava.DBF (бинарный append) + - Спланирована архитектура миграции: PharmOrder на VPS, маленький клиент на рабочем компе забирает экспорт и пишет в DBF локально +- Решения: PharmOrder можно перенести на VPS без апгрейда сервера. Единственная привязка к локалке — запись в zayava.DBF, решается клиентским скриптом. Нужна полная версия с рабочего компа. + +### 2026-03-01 — TG Bridge + SVG эксперименты (сессия 13) +- Что сделано: + - tools/tg-bridge/main.py — Telegram → Claude Code бридж через long polling + - Бот @cipher_think_bot принимает сообщения, прокидывает в `claude -p`, возвращает ответ + - Whitelist по user_id (691773226), история 20 сообщений (history.json), /new для сброса + - Фиксы: Windows encoding (PYTHONIOENCODING=utf-8), nested session (unset CLAUDECODE env) + - dotfiles-claude синкнут с текущим ~/.claude/ (aboutme, ai-knowledge обновлены, push) + - Эксперимент: image → SVG конвертация (vtracer, Inkscape CLI trace). Inkscape 64-scan лучший результат для сложных лого, но автотулы не тянут разбивку на семантические объекты + - Inkscape установлен через winget (1.4.3) +- Решения: `claude -p` без `--continue` (конфликтует с интерактивной сессией), вместо этого ручная history.json. Автоматический трейсинг упирается в потолок на сложных иллюстрациях — нужен Gemini 3.1 Pro или ручная работа. + +### 2026-03-01 — Telethon авторизован + дайджесты в продакшне (сессия 12) +- Что сделано: + - Telethon авторизован через QR-код (из PowerShell пользователя). Блокер решён! + - Сессия cortex_userbot.session создана и загружена на VM + - Первый дайджест vibecod3rs: 1494 сообщения → Claude субагент (GOOGLE_API_KEY был просрочен) + - Новый GOOGLE_API_KEY получен, Gemini digest работает + - /tg-digest скилл создан (Telethon fetch → Gemini analysis → .md в канал) + - Группа Вайбкодеры добавлена в config.py (vibecod3rs) + - AI Mindset: username не работал → заменён на числовой ID (-1001497220445) + - digest.py/daily.py: дайджест отправляется как .md файл (sendDocument), не текстом + - Systemd timer: 03:00 UTC (06:00 MSK), обе группы фетчатся + - VM: .env почищен от комментариев, все файлы синхронизированы + - PharmOrder: улучшен поиск (multi-word fallback: "нурофен леди" → "Нурофен экспресс леди") + - Создан .docx с заявкой для аптеки, отправлен маме в TG +- Решения: QR-авторизация > код (коды Telegram не доставлялись). Gemini > Claude для дайджестов (экономия токенов). .md файл > текст (нет лимита 4096 символов). + +### 2026-03-01 — TG Monitor написан (сессия 11) +- Что сделано: + - tools/tg-monitor/monitor.py — Telethon userbot, читает сообщения из TG-групп, сохраняет в JSON + - tools/tg-monitor/digest.py — дайджест через Gemini, отправка в Telegram + - tools/tg-monitor/config.py — список групп, фильтры по длине и ключевым словам + - tools/tg-monitor/daily.py — объединяет heartbeat + tg-monitor в один запуск + - tools/tg-monitor/deploy/ — systemd service + timer + setup-vm.sh + - .gitignore обновлён — data/tg-groups/ и *.session исключены +- Блокер: Telethon auth — коды не приходили (решён в сессии 12 через QR). + ### 2026-02-28 — Контент-пайплайн запущен (сессия 10) - Что сделано: - PR #46 смержен (сессия 9 docs), разрешены конфликты с Agent-Reach из main @@ -118,11 +254,12 @@ - Архитектура: CLI команды → GitHub Issues → AI агенты (Jules/Codex) → PR → Human merge - Стек: Python 3.12, uv, beartype, Claude Code CLI, GitHub Actions - Интеграции: Context7 MCP, Sequential Thinking MCP, Playwright MCP, idea-reality MCP, Codex CLI MCP +- Inkscape 1.4.3 установлен (winget), доступен для CLI trace bitmap ## Инвентарь -### Команды (12) -council, dispatch, heartbeat, handoff, status, verify, new-project, screenshot, tdd, build-fix, learn, quick-commit, metrics +### Команды (14) +council, dispatch, heartbeat, handoff, status, verify, new-project, screenshot, tdd, build-fix, learn, quick-commit, metrics, tg-digest, daily ### Агенты (4) architect, code-reviewer, security-auditor, verify-agent @@ -130,6 +267,9 @@ architect, code-reviewer, security-auditor, verify-agent ### Хуки (8) check-secrets, check-filesize, pre-commit-check, protect-main, grab-screenshot, output-secret-filter, mcp-usage-tracker, expensive-tool-warning +### Tools (4) +heartbeat (HN/Reddit/GitHub trends), pipeline (DEV_CONTEXT → article → Telegram), tg-monitor (TG groups → digest → Telegram), tg-bridge (Telegram → Claude Code bridge) + ### Workflows (4) heartbeat.yml (cron), code-review.yml (PR review), jules-trigger.yml (auto-trigger), pipeline.yml (DEV_CONTEXT → Telegram) @@ -172,11 +312,42 @@ heartbeat.yml (cron), code-review.yml (PR review), jules-trigger.yml (auto-trigg - [x] Google Cloud VM (cortex-vm, e2-small, 34.159.55.61) — задеплоен, Cortex склонирован - [x] Telegram бот подключён к приватному каналу (chat_id: -1001434709177) - [x] Контент-пайплайн: DEV_CONTEXT → Gemini 3 Flash → Telegram (PR #47, pipeline.yml) -- [ ] Перегенерить GOOGLE_API_KEY и TELEGRAM_BOT_TOKEN (засвечены в чате) +- [x] TG Monitor: Telethon userbot + Gemini digest + daily runner (tools/tg-monitor/) +- [x] Systemd timer для daily digest на VM (deploy/cortex-daily.timer) +- [x] Telethon авторизован (QR), сессия на VM, дайджесты работают +- [x] GOOGLE_API_KEY обновлён +- [x] /tg-digest скилл +- [x] TG Bridge: Telegram → Claude Code через @cipher_think_bot (tools/tg-bridge/) +- [x] dotfiles-claude синкнут с текущим состоянием +- [x] TG уведомления маме при новых заявках (relay → @cipher_think_bot → Luda) +- [x] /screenshot починен (буфер обмена → Read tool) +- [x] Cleanup: удалены init-project.sh, setup-vm.sh +- [x] PharmOrder → VPS: получить полную версию с рабочего компа +- [x] PharmOrder → VPS: деплой (194.87.140.204:8000) + auth middleware + sync endpoints +- [x] PharmOrder → VPS: sync_client.py для DBF экспорта + прайс-синк +- [x] PharmOrder → VPS: batch lookup (16x speedup), cloud history UI +- [x] PharmOrder → VPS: sync-статус в header, поиск фикс (contains), batch автозаказ + корзина +- [x] PharmOrder → VPS: UI cleanup (buildTag, stats, панели), VPS.md документация +- [x] PharmOrder → VPS: sync_standalone.py дописан (загрузка прайсов + экспорт), протестировано +- [x] Context Mode MCP установлен и работает (98% сжатие контекста) +- [x] PharmOrder → VPS: delta sync (HTTP-only, без SFTP, set-based diff) +- [x] PharmOrder → VPS: shared scan items (server-side, real-time sync 3 сек) +- [x] PharmOrder → VPS: мультикомп экосистема (домашний ПК + мамин ноутбук работают) +- [x] PharmOrder: история приходов (ReeTov.DBF → order_history.db, бейджи "72% Катрен") +- [x] PharmOrder: Созвездие матрица (О/Р маркеры в поиске, header, таблице поставщиков) +- [x] PharmOrder: sozvezdie.db с привязкой поставщиков к товарам матрицы (120K связей) +- [x] PharmOrder: batch endpoint sozvezdie-batch для поиска +- [ ] PharmOrder: прайс-чекер (нужен ReeTov.DBF с рабочего ПК, BL_ROSN_PR=0 на маминой копии) +- [ ] PharmOrder: ИИ-рекомендации (Gemini — синтез: история + цена + матрица → совет) +- [ ] PharmOrder: автозаявка (скорость продаж + остатки → прогноз) +- [ ] PharmOrder → VPS: кассовый комп (принести sklit_sync, тест) +- [ ] Перегенерить TELEGRAM_BOT_TOKEN (засвечен в чате) - [ ] Анализ экспорта Telegram канала (заметки + аудио) - [ ] ~~Фриланс-бот~~ (отложен) ## Идеи / Backlog +- Context Mode MCP (github.com/mksglu/claude-context-mode) — сжатие выхода тулов на 98%, сессии живут 3ч вместо 30мин +- PharmOrder cloud: HTTPS (caddy/nginx + certbot), накладные sync - Контент-пайплайн: DEV_CONTEXT → статья через Claude API → Telegram канал (приватный → потом публичный) - Personal OS v2: Obsidian MCP (поиск по vault в реальном времени) - Self-improving rules (агент пишет новые правила при ошибках) diff --git a/init-project.sh b/init-project.sh deleted file mode 100644 index 2b7cafd..0000000 --- a/init-project.sh +++ /dev/null @@ -1,105 +0,0 @@ -#!/bin/bash -# Инициализация нового проекта из шаблона claudeCopy -set -e - -TEMPLATE_DIR="$(cd "$(dirname "$0")" && pwd)" -DEFAULT_BASE="D:/code/2026" - -echo "========================================" -echo " Claude Code — New Project Init" -echo "========================================" -echo "" - -# Название проекта -if [ -n "$1" ]; then - PROJECT_NAME="$1" -else - read -p "Название проекта: " PROJECT_NAME -fi - -if [ -z "$PROJECT_NAME" ]; then - echo "ERROR: название проекта обязательно" - exit 1 -fi - -# Путь -if [ -n "$2" ]; then - PROJECT_DIR="$2" -else - PROJECT_DIR="$DEFAULT_BASE/$PROJECT_NAME" - read -p "Путь [$PROJECT_DIR]: " CUSTOM_DIR - if [ -n "$CUSTOM_DIR" ]; then - PROJECT_DIR="$CUSTOM_DIR" - fi -fi - -if [ -d "$PROJECT_DIR" ]; then - echo "ERROR: директория $PROJECT_DIR уже существует" - exit 1 -fi - -echo "" -echo "Создаю проект: $PROJECT_NAME" -echo "Путь: $PROJECT_DIR" -echo "" - -# Копирование шаблона -mkdir -p "$PROJECT_DIR" -rsync -a \ - --exclude='.git/' \ - --exclude='node_modules/' \ - --exclude='video_output/' \ - --exclude='__pycache__/' \ - --exclude='.venv/' \ - --exclude='.env' \ - --exclude='init-project.sh' \ - "$TEMPLATE_DIR/" "$PROJECT_DIR/" - -# Очистка DEV_CONTEXT.md -cat > "$PROJECT_DIR/DEV_CONTEXT.md" << 'DEVEOF' -# Development Context Log - -## Последнее обновление -- Дата: $(date +%Y-%m-%d) - -## Текущий статус -- Этап: Инициализация -- Последнее действие: Проект создан из шаблона -- Следующий шаг: Заполнить PROJECT_CONTEXT.md, определить стек - -## История изменений - -## Технические детали -- Архитектура: -- Ключевые зависимости: -- Интеграции: - -## Известные проблемы -- Нет - -## Прогресс -- [x] Инициализация из шаблона -- [ ] Заполнить PROJECT_CONTEXT.md -- [ ] Настроить стек -DEVEOF - -# Подставить реальную дату -sed -i "s/\$(date +%Y-%m-%d)/$(date +%Y-%m-%d)/" "$PROJECT_DIR/DEV_CONTEXT.md" - -# Git init -cd "$PROJECT_DIR" -git init -git add -A -git commit -m "init: project scaffold from claudeCopy template" - -echo "" -echo "========================================" -echo " Проект создан: $PROJECT_NAME" -echo " Путь: $PROJECT_DIR" -echo "========================================" -echo "" -echo "Следующие шаги:" -echo " 1. cd $PROJECT_DIR" -echo " 2. claude (или запусти start.bat)" -echo " 3. Заполни PROJECT_CONTEXT.md" -echo "" diff --git a/start.bat b/start.bat index 2755a51..e53f57d 100644 --- a/start.bat +++ b/start.bat @@ -8,4 +8,10 @@ echo Project: %CD% echo ======================================== echo. +:: TG Bridge — запускаем в фоне +start "" /b cmd /c "cd /d %~dp0tools\tg-bridge && uv run python main.py >nul 2>&1" +echo TG Bridge started in background + +echo. + claude "Старт сессии. Прочитай DEV_CONTEXT.md и PROJECT_CONTEXT.md. Выведи краткий статус проекта и следующий шаг." diff --git a/tools/pipeline/main.py b/tools/pipeline/main.py index cdd0f8b..01968ee 100644 --- a/tools/pipeline/main.py +++ b/tools/pipeline/main.py @@ -15,6 +15,8 @@ import os import re import sys +import tempfile +from datetime import datetime, timezone from pathlib import Path # Windows console fix @@ -30,32 +32,53 @@ MODEL = "gemini-3-flash-preview" -WRITING_STYLE = """Ты помогаешь писать короткие посты для Telegram-канала на основе логов разработки. +WRITING_STYLE = """Ты пишешь build-in-public посты для Telegram-канала про AI-разработку. -Стиль: -- Разговорный, без воды, без мотивашек -- От первого лица, как будто пишешь себе в дневник -- Матерные слова допустимы если уместны, не вставляй их специально -- Конкретика: что именно сделал, что не работало, что понял -- Незавершённые мысли — нормально - -Структура (не буквально, по ощущению): -- Что было / ситуация -- Что сделал / что не сработало -- Что понял (если есть) +Цель: человек прочитал — и забрал что-то полезное. Не дневник, а пост с мясом. -Длина: 150-350 слов. Не больше. +Структура: +1. Что делали (2-3 предложения, контекст задачи) +2. Полезное для читателя — САМОЕ ВАЖНОЕ: + - Конкретные инструменты, библиотеки, сервисы (с названиями) + - Команды, конфиги, сниппеты которые реально работают + - Грабли на которые наступили и как обошли + - Ссылки если релевантны +3. Вывод/инсайт (1-2 предложения, что вынес) -Форматирование для Telegram: -- Жирный через *текст* +Стиль: +- Разговорный, от первого лица +- Без воды, без мотивашек, без "ты молодец" +- Мат допустим если уместен +- Конкретика > абстракции + +Длина: 200-500 слов. + +Форматирование (Telegram MarkdownV2): +- Жирный: *текст* +- Код inline: `команда` +- Блок кода: ```язык\\nкод``` +- Списки через дефис - Никаких заголовков с ### -- Можно список через дефис если реально нужен + +Anti-slop правила (ОБЯЗАТЕЛЬНО): +- Миксуй длину предложений: короткие (3-5 слов) с длинными (25+). Никогда 3+ подряд одной длины +- Никогда не группируй ровно по 3 (примера, пункта, прилагательных). Два или четыре +- Запрещённые слова: ключевой, фундаментальный, трансформативный, экосистема (в переносном), ландшафт (в переносном), путешествие (в переносном), инновационный, бесшовный, комплексный, динамичный, надёжный +- Вместо "служит как", "является свидетельством", "играет важную роль" — пиши прямо что делает +- Не начинай с "В современном мире", "В эпоху AI", "В контексте" +- Не заканчивай предложения причастными оборотами типа "подчёркивая важность..." +- Не хеджируй: "некоторые считают", "эксперты утверждают" — назови конкретно кто +- Займи позицию. "Это не работает потому что..." вместо "с одной стороны X, с другой Y" +- Добавляй текстуру: оборванная мысль, самокоррекция, casual вставка в серьёзном тексте +- Используй сокращения: "не" вместо "не является", живой язык вместо канцелярита НЕ писать: -- "Таким образом...", "В заключение...", "Подводя итоги..." -- Мотивашки и "ты молодец" -- Формальный язык -- Длинные нумерованные списки""" +- "Таким образом...", "В заключение...", "Подводя итоги...", "Стоит отметить..." +- "Не просто X, а Y", "Не только X, но и Y" +- Общие фразы без конкретики ("настроил штуку", "поработал над проектом") +- Формальный язык, канцелярит +- Длинные нумерованные списки +- Абзацы с одинаковой структурой (тезис → пример → вывод) — ломай паттерн""" @beartype @@ -89,14 +112,16 @@ def generate_article(session_log: str) -> str: @beartype -def send_to_telegram(text: str, token: str, chat_id: str) -> int: - """Send message to Telegram. Returns message_id.""" - url = f"https://api.telegram.org/bot{token}/sendMessage" - response = httpx.post( - url, - json={"chat_id": chat_id, "text": text, "parse_mode": "Markdown"}, - timeout=30, - ) +def send_file_to_telegram(filepath: Path, caption: str, token: str, chat_id: str) -> int: + """Send file to Telegram. Returns message_id.""" + url = f"https://api.telegram.org/bot{token}/sendDocument" + with open(filepath, "rb") as f: + response = httpx.post( + url, + data={"chat_id": chat_id, "caption": caption}, + files={"document": (filepath.name, f)}, + timeout=30, + ) response.raise_for_status() result: dict[str, object] = response.json() message = result["result"] # type: ignore[index] @@ -130,15 +155,22 @@ def main() -> None: print(f"Generating via {MODEL}...") article = generate_article(last_session) + # Save as .md (always, even in dry-run) + date_str = datetime.now(timezone.utc).strftime("%Y-%m-%d") + md_path = ROOT / "data" / "posts" / f"cortex_post_{date_str}.md" + md_path.parent.mkdir(parents=True, exist_ok=True) + md_path.write_text(article, encoding="utf-8") + print(f"\n{'='*50}\n{article}\n{'='*50}\n") print(f"Length: {len(article)} chars") + print(f"Saved: {md_path}") if dry_run: print("Dry run — not sending to Telegram") return - # Send - msg_id = send_to_telegram(article, token, chat_id) + caption = f"Build log — {date_str}" + msg_id = send_file_to_telegram(md_path, caption, token, chat_id) print(f"Sent to Telegram: message_id={msg_id}") diff --git a/tools/tg-bridge/main.py b/tools/tg-bridge/main.py new file mode 100644 index 0000000..281ee1b --- /dev/null +++ b/tools/tg-bridge/main.py @@ -0,0 +1,184 @@ +from __future__ import annotations + +import json +import os +import subprocess +import sys +import tempfile +from pathlib import Path + +import httpx +from beartype import beartype + +CLAUDE_CWD = Path(__file__).resolve().parents[2] # cortex root +SCRIPT_DIR = Path(__file__).resolve().parent + +# load .env from cortex root +_env_file = CLAUDE_CWD / ".env" +if _env_file.exists(): + for line in _env_file.read_text().splitlines(): + line = line.strip() + if line and not line.startswith("#") and "=" in line: + key, _, value = line.partition("=") + os.environ.setdefault(key.strip(), value.strip()) + +BOT_TOKEN = os.environ.get("TELEGRAM_BOT_TOKEN", "") +ALLOWED_USERS: set[int] = {691773226} +SUBPROCESS_TIMEOUT = 120 +TELEGRAM_MAX_LENGTH = 4096 +HISTORY_FILE = SCRIPT_DIR / "history.json" +MAX_HISTORY = 20 + + +@beartype +def load_history() -> list[dict[str, str]]: + if HISTORY_FILE.exists(): + return json.loads(HISTORY_FILE.read_text("utf-8")) + return [] + + +@beartype +def save_history(history: list[dict[str, str]]) -> None: + HISTORY_FILE.write_text(json.dumps(history, ensure_ascii=False, indent=2), "utf-8") + + +@beartype +def build_prompt(history: list[dict[str, str]], message: str) -> str: + if not history: + return message + lines = ["Previous conversation (for context):"] + for entry in history: + lines.append(f"User: {entry['user']}") + lines.append(f"Assistant: {entry['assistant']}") + lines.append(f"\nNow answer this:\n{message}") + return "\n".join(lines) + + +@beartype +def api_url(method: str) -> str: + return f"https://api.telegram.org/bot{BOT_TOKEN}/{method}" + + +@beartype +def send_message(client: httpx.Client, chat_id: int, text: str) -> None: + if len(text) <= TELEGRAM_MAX_LENGTH: + client.post(api_url("sendMessage"), json={ + "chat_id": chat_id, + "text": text, + "parse_mode": "Markdown", + }) + else: + with tempfile.NamedTemporaryFile(mode="w", suffix=".md", delete=False) as f: + f.write(text) + tmp_path = f.name + try: + with open(tmp_path, "rb") as doc: + client.post(api_url("sendDocument"), data={ + "chat_id": str(chat_id), + }, files={"document": ("response.md", doc, "text/markdown")}) + finally: + os.unlink(tmp_path) + + +@beartype +def run_claude(prompt: str) -> str: + try: + env = {k: v for k, v in os.environ.items() if k != "CLAUDECODE"} + env["PYTHONIOENCODING"] = "utf-8" + result = subprocess.run( + ["claude", "-p", "--output-format", "text", prompt], + capture_output=True, + text=True, + timeout=SUBPROCESS_TIMEOUT, + cwd=str(CLAUDE_CWD), + env=env, + encoding="utf-8", + ) + output = result.stdout.strip() + if result.returncode != 0 and result.stderr: + output = output or result.stderr.strip() + return output or "(empty response)" + except subprocess.TimeoutExpired: + return "(timeout)" + except FileNotFoundError: + return "(error: claude CLI not found in PATH)" + + +@beartype +def poll(client: httpx.Client, offset: int) -> int: + resp = client.get(api_url("getUpdates"), params={ + "offset": offset, + "timeout": 30, + }, timeout=40) + updates = resp.json().get("result", []) + + for update in updates: + offset = update["update_id"] + 1 + msg = update.get("message") + if not msg: + continue + + user_id = msg.get("from", {}).get("id") + chat_id = msg["chat"]["id"] + text = msg.get("text", "") + + if user_id not in ALLOWED_USERS: + continue + if not text or text.startswith("/start"): + continue + + # /new — reset history + if text.strip() == "/new": + save_history([]) + send_message(client, chat_id, "History cleared.") + continue + + try: + print(f"<< {text[:80]}", flush=True) + send_message(client, chat_id, "thinking...") + + history = load_history() + prompt = build_prompt(history, text) + answer = run_claude(prompt) + + # save to history (keep last N) + history.append({"user": text, "assistant": answer}) + save_history(history[-MAX_HISTORY:]) + + print(f">> {answer[:80]}", flush=True) + send_message(client, chat_id, answer) + except Exception as e: + print(f"error processing message: {e}", flush=True) + + return offset + + +def main() -> None: + if not BOT_TOKEN: + print("TELEGRAM_BOT_TOKEN not set. Add it to .env or export it.") + sys.exit(1) + + print(f"tg-bridge started | cwd={CLAUDE_CWD} | history={MAX_HISTORY} msgs", flush=True) + offset = 0 + + with httpx.Client() as client: + # flush old updates + resp = client.get(api_url("getUpdates"), params={"offset": -1}, timeout=10) + updates = resp.json().get("result", []) + if updates: + offset = updates[-1]["update_id"] + 1 + + while True: + try: + offset = poll(client, offset) + except httpx.ReadTimeout: + continue + except KeyboardInterrupt: + print("\nstopped") + break + except Exception as e: + print(f"error: {e}") + + +if __name__ == "__main__": + main() diff --git a/tools/tg-bridge/pyproject.toml b/tools/tg-bridge/pyproject.toml new file mode 100644 index 0000000..ed4563f --- /dev/null +++ b/tools/tg-bridge/pyproject.toml @@ -0,0 +1,9 @@ +[project] +name = "tg-bridge" +version = "0.1.0" +description = "Telegram → Claude Code bridge" +requires-python = ">=3.12" +dependencies = [ + "httpx>=0.27", + "beartype>=0.19", +] diff --git a/tools/tg-bridge/uv.lock b/tools/tg-bridge/uv.lock new file mode 100644 index 0000000..097a70b --- /dev/null +++ b/tools/tg-bridge/uv.lock @@ -0,0 +1,104 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[[package]] +name = "anyio" +version = "4.12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, +] + +[[package]] +name = "beartype" +version = "0.22.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/94/1009e248bbfbab11397abca7193bea6626806be9a327d399810d523a07cb/beartype-0.22.9.tar.gz", hash = "sha256:8f82b54aa723a2848a56008d18875f91c1db02c32ef6a62319a002e3e25a975f", size = 1608866, upload-time = "2025-12-13T06:50:30.72Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/cc/18245721fa7747065ab478316c7fea7c74777d07f37ae60db2e84f8172e8/beartype-0.22.9-py3-none-any.whl", hash = "sha256:d16c9bbc61ea14637596c5f6fbff2ee99cbe3573e46a716401734ef50c3060c2", size = 1333658, upload-time = "2025-12-13T06:50:28.266Z" }, +] + +[[package]] +name = "certifi" +version = "2026.2.25" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "tg-bridge" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "beartype" }, + { name = "httpx" }, +] + +[package.metadata] +requires-dist = [ + { name = "beartype", specifier = ">=0.19" }, + { name = "httpx", specifier = ">=0.27" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] diff --git a/tools/tg-monitor/README.md b/tools/tg-monitor/README.md new file mode 100644 index 0000000..22b4d14 --- /dev/null +++ b/tools/tg-monitor/README.md @@ -0,0 +1,70 @@ +# TG Monitor — Telegram Group Parser + Daily Digest + +Читает сообщения из Telegram-групп через Telethon userbot, генерирует дайджест через Gemini 3 Flash, отправляет в приватный канал. + +## Файлы + +| Файл | Что делает | +|------|-----------| +| `config.py` | Список групп, фильтры, env vars | +| `monitor.py` | Telethon — чтение сообщений из групп → JSON | +| `digest.py` | JSON → Gemini 3 Flash → дайджест → Telegram | +| `daily.py` | Объединяет heartbeat + tg-monitor в один запуск | +| `deploy/` | Systemd service + timer + setup script для VM | + +## Env vars + +```env +# Telethon userbot +TG_API_ID=36203046 +TG_API_HASH= + +# Gemini +GOOGLE_API_KEY= + +# Bot для отправки дайджеста +TELEGRAM_BOT_TOKEN= +TELEGRAM_CHAT_ID=-1001434709177 +``` + +## Использование + +```bash +# 1. Скачать сообщения (первый запуск — попросит телефон + код) +uv run python tools/tg-monitor/monitor.py --limit 200 + +# 2. Сгенерировать и отправить дайджест +uv run python tools/tg-monitor/digest.py + +# 3. Полный цикл (heartbeat + tg + send) +uv run python tools/tg-monitor/daily.py + +# Dry run (без отправки) +uv run python tools/tg-monitor/daily.py --dry-run +``` + +## Деплой на VM + +```bash +gcloud compute ssh cortex-vm --zone=europe-west3-b +bash tools/tg-monitor/deploy/setup-vm.sh +``` + +## Добавление групп + +Отредактируй `GROUPS` в `config.py`: + +```python +GROUPS = [ + GroupConfig( + name="AI Mindset", + identifier="aimindset_chat", # @username или числовой ID + min_length=40, + keywords=["ai", "agent", ...], + ), + GroupConfig( + name="Another Group", + identifier=-100123456789, # числовой ID + ), +] +``` diff --git a/tools/tg-monitor/auth.py b/tools/tg-monitor/auth.py new file mode 100644 index 0000000..35a0df6 --- /dev/null +++ b/tools/tg-monitor/auth.py @@ -0,0 +1,128 @@ +"""Telethon auth. + +QR (recommended): python auth.py --qr +Phone code: python auth.py +79XXXXXXXXX + then: python auth.py +79XXXXXXXXX [2FA_PASSWORD] + +Env vars: TG_API_ID, TG_API_HASH (required). +""" +from __future__ import annotations + +import asyncio +import os +import sys +from pathlib import Path + +from telethon import TelegramClient # type: ignore[import-untyped] + +DATA_DIR = Path(__file__).parent.parent.parent / "data" / "tg-groups" + + +def _get_client() -> TelegramClient: + api_id = int(os.environ.get("TG_API_ID", "0")) + api_hash = os.environ.get("TG_API_HASH", "") + + if not api_id or not api_hash: + print("Error: TG_API_ID and TG_API_HASH required") + sys.exit(1) + + DATA_DIR.mkdir(parents=True, exist_ok=True) + session_name = os.environ.get("TG_SESSION_NAME", "cortex_userbot") + session_path = str(DATA_DIR / session_name) + return TelegramClient(session_path, api_id, api_hash) + + +async def qr_auth() -> None: + """QR code auth — scan from Telegram mobile.""" + try: + import qrcode + except ImportError: + print("pip install qrcode[pil]") + sys.exit(1) + + client = _get_client() + await client.connect() + + qr_login = await client.qr_login() + + # Save QR as image + img = qrcode.make(qr_login.url) + qr_path = DATA_DIR / "qr_login.png" + img.save(str(qr_path)) + print(f"QR saved: {qr_path.resolve()}") + print(f"URL: {qr_login.url}") + print() + print(">>> Open qr_login.png and scan with Telegram mobile:") + print(">>> Settings -> Devices -> Link Desktop Device") + print() + print("Waiting 120 seconds for scan...") + + # Auto-open the image + if sys.platform == "win32": + os.startfile(str(qr_path.resolve())) # type: ignore[attr-defined] + + try: + await asyncio.wait_for(qr_login.wait(), timeout=120) + except asyncio.TimeoutError: + print("Timeout — no scan detected. Try again.") + await client.disconnect() + return + + me = await client.get_me() + print(f"OK! Authorized as: {me.first_name} (id={me.id}, phone={me.phone})") # type: ignore[union-attr] + await client.disconnect() + print("Session saved!") + + +async def request_code(phone: str) -> None: + """Step 1: send code request to Telegram.""" + client = _get_client() + await client.connect() + + result = await client.send_code_request(phone) + print(f"Code sent! Type: {type(result.type).__name__}") + print(f"Phone code hash: {result.phone_code_hash}") + print(f"\nNow run: python auth.py {phone} ") + + await client.disconnect() + + +async def sign_in(phone: str, code: str, password: str | None = None) -> None: + """Step 2: sign in with the code.""" + client = _get_client() + await client.connect() + + try: + await client.sign_in(phone=phone, code=code) + except Exception as e: + if "Two-steps verification" in str(e) or "SessionPasswordNeeded" in type(e).__name__: + if not password: + print("2FA enabled! Run: python auth.py +79... ") + await client.disconnect() + sys.exit(1) + await client.sign_in(password=password) + else: + raise + + me = await client.get_me() + print(f"OK! Authorized as: {me.first_name} (id={me.id})") # type: ignore[union-attr] + await client.disconnect() + print("Session saved!") + + +if __name__ == "__main__": + if len(sys.argv) < 2: + print("Usage:") + print(" QR: python auth.py --qr") + print(" Step 1: python auth.py +79XXXXXXXXX") + print(" Step 2: python auth.py +79XXXXXXXXX [PASSWORD]") + sys.exit(1) + + if sys.argv[1] == "--qr": + asyncio.run(qr_auth()) + elif len(sys.argv) >= 3: + code = sys.argv[2] + pwd = sys.argv[3] if len(sys.argv) >= 4 else None + asyncio.run(sign_in(sys.argv[1], code, pwd)) + else: + asyncio.run(request_code(sys.argv[1])) diff --git a/tools/tg-monitor/config.py b/tools/tg-monitor/config.py new file mode 100644 index 0000000..3bfe8ba --- /dev/null +++ b/tools/tg-monitor/config.py @@ -0,0 +1,63 @@ +"""Configuration for Telegram group monitor.""" +from __future__ import annotations + +import os +from dataclasses import dataclass, field +from pathlib import Path + +ROOT = Path(__file__).parent.parent.parent +DATA_DIR = ROOT / "data" / "tg-groups" + +# Telegram userbot (Telethon) +TG_API_ID = int(os.environ.get("TG_API_ID", "0")) +TG_API_HASH = os.environ.get("TG_API_HASH", "") +TG_SESSION_NAME = os.environ.get("TG_SESSION_NAME", "cortex_userbot") + +# Telegram bot (for sending digests) +TELEGRAM_BOT_TOKEN = os.environ.get("TELEGRAM_BOT_TOKEN", "") +TELEGRAM_CHAT_ID = os.environ.get("TELEGRAM_CHAT_ID", "-1001434709177") + +# Gemini +GEMINI_MODEL = "gemini-3.1-pro-preview" + +# Digest window (hours) +DIGEST_WINDOW_HOURS = 24 + + +@dataclass +class GroupConfig: + """A Telegram group to monitor.""" + + name: str + # Either username (@group) or numeric ID + identifier: str | int + # Minimum message length to include (skip "hi", "thanks") + min_length: int = 50 + # Keywords to boost relevance (empty = include all long enough messages) + keywords: list[str] = field(default_factory=list) + + +# Groups to monitor +GROUPS: list[GroupConfig] = [ + GroupConfig( + name="AI Mindset (Серёжа Рис)", + identifier=-1001497220445, + min_length=40, + keywords=[ + "ai", "agent", "llm", "gpt", "claude", "prompt", + "model", "api", "deploy", "tool", "mcp", + "бизнес", "продукт", "автоматизация", "нейросеть", + ], + ), + GroupConfig( + name="Вайбкодеры", + identifier="vibecod3rs", + min_length=40, + keywords=[ + "ai", "agent", "llm", "claude", "codex", "cursor", + "vibe", "coding", "mcp", "prompt", "api", "deploy", + "rust", "python", "typescript", "framework", + "openclaw", "gemini", "opus", "sonnet", + ], + ), +] diff --git a/tools/tg-monitor/daily.py b/tools/tg-monitor/daily.py new file mode 100644 index 0000000..659a9f5 --- /dev/null +++ b/tools/tg-monitor/daily.py @@ -0,0 +1,180 @@ +"""Daily Digest Runner — combines heartbeat + tg-monitor into one run. + +Usage: + uv run python tools/tg-monitor/daily.py # full run + uv run python tools/tg-monitor/daily.py --dry-run # no telegram + uv run python tools/tg-monitor/daily.py --skip-heartbeat # tg-monitor only + uv run python tools/tg-monitor/daily.py --skip-tg # heartbeat only + +Requires env: + TG_API_ID, TG_API_HASH — for Telethon userbot + GOOGLE_API_KEY — for Gemini digest + TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID — for sending +""" +from __future__ import annotations + +import asyncio +import io +import os +import subprocess +import sys +from datetime import datetime, timezone +from pathlib import Path + +if sys.stdout.encoding != "utf-8": + sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace") + +import httpx +from beartype import beartype + +ROOT = Path(__file__).parent.parent.parent +HEARTBEAT_DIR = ROOT / "tools" / "heartbeat" +TG_MONITOR_DIR = Path(__file__).parent + + +@beartype +def send_file_to_telegram(filepath: Path, caption: str, token: str, chat_id: str) -> int: + """Send file to Telegram. Returns message_id.""" + url = f"https://api.telegram.org/bot{token}/sendDocument" + with open(filepath, "rb") as f: + response = httpx.post( + url, + data={"chat_id": chat_id, "caption": caption}, + files={"document": (filepath.name, f)}, + timeout=30, + ) + response.raise_for_status() + result: dict[str, object] = response.json() + message = result["result"] # type: ignore[index] + return int(message["message_id"]) # type: ignore[index] + + +@beartype +def run_heartbeat() -> str | None: + """Run heartbeat fetch and return raw markdown.""" + print("\n[1/3] Running Heartbeat...") + try: + result = subprocess.run( + [sys.executable, "main.py", "--mode", "fetch"], + cwd=str(HEARTBEAT_DIR), + capture_output=True, + text=True, + timeout=120, + encoding="utf-8", + ) + if result.returncode != 0: + print(f" Heartbeat failed: {result.stderr[:200]}") + return None + output = result.stdout.strip() + print(f" Heartbeat: {len(output)} chars") + return output + except Exception as e: + print(f" Heartbeat error: {e}") + return None + + +@beartype +def run_tg_fetch() -> bool: + """Fetch new messages from TG groups.""" + print("\n[2/3] Fetching TG groups...") + try: + # Import and run directly to reuse the same event loop + sys.path.insert(0, str(TG_MONITOR_DIR)) + from monitor import run as monitor_run + + asyncio.run(monitor_run(limit=200, group_filter=None)) + return True + except Exception as e: + print(f" TG fetch error: {e}") + return False + + +@beartype +def run_tg_digest(dry_run: bool, hours: int) -> str | None: + """Generate TG group digest.""" + print("\n[3/3] Generating TG digest...") + try: + sys.path.insert(0, str(TG_MONITOR_DIR)) + from digest import generate_digest, load_recent_messages, format_messages_for_llm + + groups_messages = load_recent_messages(hours) + if not groups_messages: + print(" No recent TG messages") + return None + + parts: list[str] = [] + for group_name, messages in groups_messages.items(): + messages_text = format_messages_for_llm(group_name, messages) + digest = generate_digest(group_name, messages_text) + parts.append(digest) + print(f" {group_name}: {len(digest)} chars") + + return "\n\n---\n\n".join(parts) + except Exception as e: + print(f" TG digest error: {e}") + return None + + +def main() -> None: + import argparse + + parser = argparse.ArgumentParser(description="Daily Digest Runner") + parser.add_argument("--dry-run", action="store_true", help="Don't send to Telegram") + parser.add_argument("--skip-heartbeat", action="store_true", help="Skip heartbeat") + parser.add_argument("--skip-tg", action="store_true", help="Skip TG monitor") + parser.add_argument("--hours", type=int, default=24, help="Time window for TG digest") + args = parser.parse_args() + + token = os.environ.get("TELEGRAM_BOT_TOKEN", "") + chat_id = os.environ.get("TELEGRAM_CHAT_ID", "") + if not args.dry_run and (not token or not chat_id): + print("Error: TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID required", file=sys.stderr) + sys.exit(1) + + now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC") + print(f"Daily Digest — {now}") + + parts: list[str] = [] + + # Heartbeat + if not args.skip_heartbeat: + hb = run_heartbeat() + if hb: + # Trim heartbeat to key findings (first 1500 chars) + summary = hb[:1500] + if len(hb) > 1500: + summary += "\n..." + parts.append(f"*Heartbeat — {now[:10]}*\n\n{summary}") + + # TG Monitor + if not args.skip_tg: + run_tg_fetch() + digest = run_tg_digest(args.dry_run, args.hours) + if digest: + parts.append(digest) + + if not parts: + print("\nNothing to report today.") + return + + full_digest = "\n\n---\n\n".join(parts) + print(f"\n{'='*50}\n{full_digest}\n{'='*50}") + print(f"Total length: {len(full_digest)} chars") + + if args.dry_run: + print("\nDry run — not sending") + return + + # Save as .md and send as file + date_str = datetime.now(timezone.utc).strftime("%Y-%m-%d") + md_path = Path(f"/tmp/daily_digest_{date_str}.md") + md_path.write_text(full_digest, encoding="utf-8") + print(f"\nSaved: {md_path}") + + caption = f"Daily Digest — {date_str}" + msg_id = send_file_to_telegram(md_path, caption, token, chat_id) + print(f"Sent to Telegram: message_id={msg_id}") + + +if __name__ == "__main__": + main() diff --git a/tools/tg-monitor/deploy/cortex-daily.service b/tools/tg-monitor/deploy/cortex-daily.service new file mode 100644 index 0000000..ea9608c --- /dev/null +++ b/tools/tg-monitor/deploy/cortex-daily.service @@ -0,0 +1,15 @@ +[Unit] +Description=Cortex Daily Digest (TG Monitor + Heartbeat) +After=network.target + +[Service] +Type=oneshot +User=root +WorkingDirectory=/opt/cortex +EnvironmentFile=/opt/cortex/.env +ExecStart=/opt/cortex/.venv/bin/python tools/tg-monitor/daily.py +StandardOutput=journal +StandardError=journal + +[Install] +WantedBy=multi-user.target diff --git a/tools/tg-monitor/deploy/cortex-daily.timer b/tools/tg-monitor/deploy/cortex-daily.timer new file mode 100644 index 0000000..6cfe4ec --- /dev/null +++ b/tools/tg-monitor/deploy/cortex-daily.timer @@ -0,0 +1,9 @@ +[Unit] +Description=Run Cortex Daily Digest every morning + +[Timer] +OnCalendar=*-*-* 08:00:00 +Persistent=true + +[Install] +WantedBy=timers.target diff --git a/tools/tg-monitor/digest.py b/tools/tg-monitor/digest.py new file mode 100644 index 0000000..9c71cc7 --- /dev/null +++ b/tools/tg-monitor/digest.py @@ -0,0 +1,363 @@ +"""Telegram Group Digest — MapReduce pipeline. + +Pipeline: Enrich → MAP (parallel chunks) → REDUCE → VERIFY +Adapted from sereja.tech/blog/digest-subagents-mapreduce + +Usage: + uv run python tools/tg-monitor/digest.py # generate + send + uv run python tools/tg-monitor/digest.py --dry-run # print only + uv run python tools/tg-monitor/digest.py --hours 48 # last 48h + +Requires env: + GOOGLE_API_KEY, TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID +""" +from __future__ import annotations + +import io +import json +import math +import os +import sys +from datetime import datetime, timedelta, timezone +from pathlib import Path + +if sys.stdout.encoding != "utf-8": + sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace") + +import httpx +from beartype import beartype +from google import genai + +from config import ( + DATA_DIR, + DIGEST_WINDOW_HOURS, + GEMINI_MODEL, + GROUPS, + TELEGRAM_BOT_TOKEN, + TELEGRAM_CHAT_ID, +) + +# Fast model for MAP/VERIFY (cheap), Pro for REDUCE (quality) +GEMINI_MODEL_FAST = "gemini-2.0-flash" + +CHUNK_SIZE = 15 # messages per MAP chunk + +MAP_PROMPT = """Извлеки ОДНУ главную тему из этого кластера сообщений Telegram-группы. + +Правила: +- Конкретика: имена участников, инструменты, числа, ссылки +- Если есть URL — обязательно включи с описанием +- Не додумывай — только то что явно написано +- 100-300 слов +- Русский, технические термины на английском + +Формат: +**Тема: [название]** +[Описание с деталями] +""" + +REDUCE_PROMPT = """Собери дайджест Telegram-группы из извлечённых тем. + +Структура: +1. *Горячие темы* — что обсуждали, ключевые позиции участников +2. *Инструменты* — что упоминается, что хвалят/ругают +3. *Ссылки и ресурсы* — каждый URL с описанием +4. *Кейсы* — кто что делает, результаты +5. *Выводы* — тренды, практические советы + +Правила: +- 500-1500 слов +- Конкретика: имена, инструменты, числа +- Не пропускай ссылки +- Формат для Telegram: *жирный*, списки через дефис +- Русский, технические термины на английском +- Пропусти болтовню, оффтоп +- Не пиши "В заключение...", "Подводя итоги..." +- Если ничего интересного — так и скажи + +Формат: +*Дайджест {group_name} — {date}* + +[Структурированный отчёт] +""" + +VERIFY_PROMPT = """Проверь дайджест на фактическую точность. + +Сверь КАЖДОЕ утверждение дайджеста с исходными сообщениями. + +Для каждого факта: +- CONFIRMED — есть прямое подтверждение в сообщениях +- UNVERIFIED — нет источника, но возможно +- HALLUCINATION — противоречит сообщениям или выдумано + +Формат: +ФАКТ: [утверждение] +СТАТУС: [CONFIRMED/UNVERIFIED/HALLUCINATION] +ИСТОЧНИК: [цитата из сообщения или "не найден"] + +В конце: итого X confirmed, Y unverified, Z hallucinations. +Если есть HALLUCINATION — предложи исправление. +""" + + +# --- Enrichment --- + +@beartype +def enrich_messages(messages: list[dict[str, object]]) -> list[dict[str, object]]: + """Add engagement scores and resolve reply_to context.""" + msg_index: dict[int, dict[str, object]] = {} + for msg in messages: + msg_index[int(msg["message_id"])] = msg # type: ignore[arg-type] + + # Count replies per message + reply_counts: dict[int, int] = {} + thread_sizes: dict[int, int] = {} + for msg in messages: + reply_to = msg.get("reply_to") + if reply_to is not None: + rid = int(reply_to) # type: ignore[arg-type] + reply_counts[rid] = reply_counts.get(rid, 0) + 1 + # Thread = root message + thread_sizes[rid] = thread_sizes.get(rid, 0) + 1 + + enriched: list[dict[str, object]] = [] + for msg in messages: + mid = int(msg["message_id"]) # type: ignore[arg-type] + replies = reply_counts.get(mid, 0) + thread = thread_sizes.get(mid, 0) + score = replies * 2.0 + thread * 0.5 + + enriched_msg = dict(msg) + enriched_msg["engagement_score"] = score + + # Resolve reply_to text + reply_to = msg.get("reply_to") + if reply_to is not None: + parent = msg_index.get(int(reply_to)) # type: ignore[arg-type] + if parent: + enriched_msg["reply_to_text"] = parent.get("text", "") + enriched_msg["reply_to_sender"] = parent.get("sender_name", "") + + enriched.append(enriched_msg) + + return enriched + + +@beartype +def filter_top_messages(messages: list[dict[str, object]], top_n: int = 100) -> list[dict[str, object]]: + """Filter top messages by engagement score (min 2.0), keep chronological order.""" + scored = [m for m in messages if float(m.get("engagement_score", 0)) >= 2.0] # type: ignore[arg-type] + scored.sort(key=lambda m: float(m.get("engagement_score", 0)), reverse=True) # type: ignore[arg-type] + top = scored[:top_n] + # Restore chronological order + top.sort(key=lambda m: str(m.get("date", ""))) + return top + + +# --- Format --- + +@beartype +def format_chunk(messages: list[dict[str, object]]) -> str: + """Format a chunk of messages for LLM.""" + lines: list[str] = [] + for msg in messages: + sender = msg.get("sender_name", "Unknown") + text = str(msg.get("text", "")) + date = str(msg.get("date", ""))[:16] + + reply_ctx = "" + reply_text = msg.get("reply_to_text") + reply_sender = msg.get("reply_to_sender") + if reply_text: + short = str(reply_text)[:100] + reply_ctx = f" [ответ на {reply_sender}: {short}]" + + lines.append(f"[{date}] {sender}:{reply_ctx} {text}") + return "\n".join(lines) + + +# --- Gemini calls --- + +@beartype +def call_gemini(prompt: str, content: str, model: str | None = None) -> str: + """Single Gemini API call.""" + client = genai.Client() + response = client.models.generate_content( + model=model or GEMINI_MODEL, + contents=f"{prompt}\n\n{content}", + ) + return response.text or "" + + +# --- Pipeline --- + +@beartype +def pipeline_map(chunks: list[list[dict[str, object]]]) -> list[str]: + """MAP: extract one topic per chunk (fast model).""" + topics: list[str] = [] + for i, chunk in enumerate(chunks): + formatted = format_chunk(chunk) + print(f" MAP [{i+1}/{len(chunks)}] ({len(chunk)} msgs)...") + topic = call_gemini(MAP_PROMPT, formatted, model=GEMINI_MODEL_FAST) + topics.append(topic) + return topics + + +@beartype +def pipeline_reduce(group_name: str, topics: list[str]) -> str: + """REDUCE: assemble topics into final digest (pro model).""" + date_str = datetime.now(timezone.utc).strftime("%d.%m.%Y") + prompt = REDUCE_PROMPT.format(group_name=group_name, date=date_str) + combined = "\n\n---\n\n".join(f"Тема {i+1}:\n{t}" for i, t in enumerate(topics)) + print(f" REDUCE ({len(topics)} topics → digest)...") + return call_gemini(prompt, combined) + + +@beartype +def pipeline_verify(digest: str, messages: list[dict[str, object]]) -> str: + """VERIFY: fact-check digest against source messages (fast model).""" + source = format_chunk(messages[:50]) # top-50 for context window + content = f"ДАЙДЖЕСТ:\n{digest}\n\nИСХОДНЫЕ СООБЩЕНИЯ:\n{source}" + print(" VERIFY (fact-check)...") + return call_gemini(VERIFY_PROMPT, content, model=GEMINI_MODEL_FAST) + + +# --- Load --- + +@beartype +def load_recent_messages(hours: int) -> dict[str, list[dict[str, object]]]: + """Load messages from all groups within the time window.""" + cutoff = datetime.now(timezone.utc) - timedelta(hours=hours) + result: dict[str, list[dict[str, object]]] = {} + + for group in GROUPS: + safe_name = group.name.lower().replace(" ", "_").replace("(", "").replace(")", "") + path = DATA_DIR / f"{safe_name}.json" + if not path.exists(): + print(f" No data for {group.name} ({path})") + continue + + all_msgs: list[dict[str, object]] = json.loads(path.read_text(encoding="utf-8")) + recent = [] + for msg in all_msgs: + date_str = str(msg.get("date", "")) + if not date_str: + continue + try: + msg_date = datetime.fromisoformat(date_str) + if msg_date.tzinfo is None: + msg_date = msg_date.replace(tzinfo=timezone.utc) + if msg_date >= cutoff: + recent.append(msg) + except ValueError: + continue + + if recent: + result[group.name] = recent + print(f" {group.name}: {len(recent)} messages in last {hours}h") + else: + print(f" {group.name}: no messages in last {hours}h") + + return result + + +# --- Send --- + +@beartype +def send_file_to_telegram(filepath: Path, caption: str, token: str, chat_id: str) -> int: + """Send file to Telegram. Returns message_id.""" + url = f"https://api.telegram.org/bot{token}/sendDocument" + with open(filepath, "rb") as f: + response = httpx.post( + url, + data={"chat_id": chat_id, "caption": caption}, + files={"document": (filepath.name, f)}, + timeout=30, + ) + response.raise_for_status() + result: dict[str, object] = response.json() + message = result["result"] # type: ignore[index] + return int(message["message_id"]) # type: ignore[index] + + +# --- Main --- + +def main() -> None: + import argparse + + parser = argparse.ArgumentParser(description="TG Group Digest (MapReduce)") + parser.add_argument("--dry-run", action="store_true", help="Print only, don't send") + parser.add_argument("--hours", type=int, default=DIGEST_WINDOW_HOURS, help="Time window in hours") + parser.add_argument("--no-verify", action="store_true", help="Skip verification step") + args = parser.parse_args() + + if not os.environ.get("GOOGLE_API_KEY"): + print("Error: GOOGLE_API_KEY required", file=sys.stderr) + sys.exit(1) + + token = TELEGRAM_BOT_TOKEN + chat_id = TELEGRAM_CHAT_ID + if not args.dry_run and (not token or not chat_id): + print("Error: TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID required", file=sys.stderr) + sys.exit(1) + + print(f"Loading messages from last {args.hours}h...") + groups_messages = load_recent_messages(args.hours) + + if not groups_messages: + print("No recent messages found. Run monitor.py first.") + sys.exit(0) + + for group_name, messages in groups_messages.items(): + print(f"\n{'='*50}") + print(f"Pipeline for {group_name} ({len(messages)} messages)") + print(f"{'='*50}") + + # 1. Enrich + print(" ENRICH: engagement scores + reply_to context...") + enriched = enrich_messages(messages) + + # 2. Filter + top = filter_top_messages(enriched) + if len(top) < 5: + # Not enough engaging messages, use all (sorted by date) + top = sorted(enriched, key=lambda m: str(m.get("date", ""))) + print(f" FILTER: {len(messages)} → {len(top)} messages (score >= 2.0)") + + # 3. MAP: split into chunks + n_chunks = max(1, math.ceil(len(top) / CHUNK_SIZE)) + chunks = [top[i * CHUNK_SIZE:(i + 1) * CHUNK_SIZE] for i in range(n_chunks)] + print(f" MAP: {n_chunks} chunks × ~{CHUNK_SIZE} messages") + topics = pipeline_map(chunks) + + # 4. REDUCE + digest = pipeline_reduce(group_name, topics) + print(f"\n{digest}\n") + print(f"Length: {len(digest)} chars") + + # 5. VERIFY + if not args.no_verify: + verify_result = pipeline_verify(digest, top) + print(f"\nVERIFY:\n{verify_result}") + + if "HALLUCINATION" in verify_result.upper(): + print("\n WARNING: hallucinations detected, check verify output above") + + if args.dry_run: + print("Dry run — not sending") + continue + + # Save and send + safe_name = group_name.lower().replace(" ", "_").replace("(", "").replace(")", "") + date_str = datetime.now(timezone.utc).strftime("%Y-%m-%d") + md_path = DATA_DIR / f"{safe_name}_digest_{date_str}.md" + md_path.write_text(digest, encoding="utf-8") + print(f"Saved: {md_path}") + + caption = f"Дайджест {group_name} — {date_str}" + msg_id = send_file_to_telegram(md_path, caption, token, chat_id) + print(f"Sent to Telegram: message_id={msg_id}") + + +if __name__ == "__main__": + main() diff --git a/tools/tg-monitor/monitor.py b/tools/tg-monitor/monitor.py new file mode 100644 index 0000000..33ada97 --- /dev/null +++ b/tools/tg-monitor/monitor.py @@ -0,0 +1,168 @@ +"""Telegram Group Monitor — read messages via Telethon userbot. + +Usage: + uv run python tools/tg-monitor/monitor.py # fetch all groups + uv run python tools/tg-monitor/monitor.py --limit 50 # last 50 messages + uv run python tools/tg-monitor/monitor.py --group aimindset_chat + +Requires env: + TG_API_ID, TG_API_HASH + (first run will ask for phone number + code for auth) +""" +from __future__ import annotations + +import asyncio +import io +import json +import sys +from dataclasses import asdict, dataclass +from datetime import datetime, timezone +from pathlib import Path + +if sys.stdout.encoding != "utf-8": + sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace") + +from beartype import beartype +from telethon import TelegramClient # type: ignore[import-untyped] +from telethon.tl.types import Message # type: ignore[import-untyped] + +from config import DATA_DIR, GROUPS, TG_API_HASH, TG_API_ID, TG_SESSION_NAME, GroupConfig + + +@dataclass +class SavedMessage: + """A message saved from a Telegram group.""" + + group: str + sender_id: int + sender_name: str + text: str + date: str # ISO format + message_id: int + reply_to: int | None = None + has_media: bool = False + + +@beartype +def output_path(group_name: str) -> Path: + """Get output JSON path for a group.""" + safe_name = group_name.lower().replace(" ", "_").replace("(", "").replace(")", "") + return DATA_DIR / f"{safe_name}.json" + + +@beartype +def load_existing(path: Path) -> list[dict[str, object]]: + """Load existing messages from JSON file.""" + if not path.exists(): + return [] + data: list[dict[str, object]] = json.loads(path.read_text(encoding="utf-8")) + return data + + +@beartype +def save_messages(path: Path, messages: list[dict[str, object]]) -> None: + """Save messages to JSON, deduplicating by message_id.""" + seen: set[int] = set() + unique: list[dict[str, object]] = [] + for msg in messages: + mid = int(msg["message_id"]) # type: ignore[arg-type] + if mid not in seen: + seen.add(mid) + unique.append(msg) + # Sort by date descending + unique.sort(key=lambda m: str(m.get("date", "")), reverse=True) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(unique, ensure_ascii=False, indent=2), encoding="utf-8") + + +@beartype +async def fetch_group( + client: TelegramClient, # type: ignore[type-arg] + group: GroupConfig, + limit: int, +) -> list[SavedMessage]: + """Fetch last N messages from a group.""" + print(f" Fetching {group.name} ({group.identifier})...") + + entity = await client.get_entity(group.identifier) # type: ignore[arg-type] + messages: list[SavedMessage] = [] + + msg: Message + async for msg in client.iter_messages(entity, limit=limit): # type: ignore[union-attr] + if not isinstance(msg, Message) or not msg.text: + continue + if len(msg.text) < group.min_length: + continue + + sender_name = "" + if msg.sender: + sender_name = getattr(msg.sender, "first_name", "") or "" + last = getattr(msg.sender, "last_name", "") or "" + if last: + sender_name = f"{sender_name} {last}" + + messages.append( + SavedMessage( + group=group.name, + sender_id=msg.sender_id or 0, + sender_name=sender_name, + text=msg.text, + date=msg.date.isoformat() if msg.date else "", + message_id=msg.id, + reply_to=msg.reply_to.reply_to_msg_id if msg.reply_to else None, + has_media=msg.media is not None, + ) + ) + + print(f" Got {len(messages)} messages (of {limit} checked)") + return messages + + +@beartype +async def run(limit: int, group_filter: str | None) -> None: + """Main async entry point.""" + if not TG_API_ID or not TG_API_HASH: + print("Error: TG_API_ID and TG_API_HASH required", file=sys.stderr) + sys.exit(1) + + session_path = str(DATA_DIR / TG_SESSION_NAME) + client = TelegramClient(session_path, TG_API_ID, TG_API_HASH) + await client.start() # type: ignore[func-returns-value] + + print(f"Connected as: {(await client.get_me()).first_name}") # type: ignore[union-attr] + + groups = GROUPS + if group_filter: + groups = [g for g in GROUPS if group_filter in g.identifier or group_filter in g.name.lower()] # type: ignore[operator] + if not groups: + print(f"No group matching '{group_filter}'", file=sys.stderr) + sys.exit(1) + + for group in groups: + messages = await fetch_group(client, group, limit) + if not messages: + print(f" No messages for {group.name}") + continue + + path = output_path(group.name) + existing = load_existing(path) + all_msgs = [asdict(m) for m in messages] + existing + save_messages(path, all_msgs) # type: ignore[arg-type] + print(f" Saved to {path} ({len(messages)} new)") + + await client.disconnect() + + +def main() -> None: + import argparse + + parser = argparse.ArgumentParser(description="Telegram Group Monitor") + parser.add_argument("--limit", type=int, default=200, help="Messages to fetch per group") + parser.add_argument("--group", type=str, default=None, help="Filter by group identifier") + args = parser.parse_args() + + asyncio.run(run(args.limit, args.group)) + + +if __name__ == "__main__": + main() diff --git a/tools/tg-monitor/pyproject.toml b/tools/tg-monitor/pyproject.toml new file mode 100644 index 0000000..a123773 --- /dev/null +++ b/tools/tg-monitor/pyproject.toml @@ -0,0 +1,18 @@ +[project] +name = "cortex-tg-monitor" +version = "0.1.0" +requires-python = ">=3.10" +dependencies = [ + "telethon>=1.36.0", + "google-genai>=1.0.0", + "beartype>=0.18.0", + "httpx>=0.27.0", +] + +[tool.mypy] +strict = true +python_version = "3.12" + +[tool.pyright] +typeCheckingMode = "strict" +pythonVersion = "3.12"