From 15a9e1d2de7cb418c3609d6236d14295448f6c5c Mon Sep 17 00:00:00 2001 From: gilfanovr Date: Sun, 18 Oct 2026 20:14:31 +0300 Subject: [PATCH] =?UTF-8?q?rpi:=20tg=20digest=20=E2=80=94=20=D0=B2=D0=B5?= =?UTF-8?q?=D1=87=D0=B5=D1=80=D0=BD=D0=B8=D0=B9=20=D1=80=D0=B5=D0=BA=D0=B0?= =?UTF-8?q?=D0=BF=20=D0=B3=D1=80=D1=83=D0=BF=D0=BF=D1=8B=20=D1=86=D0=B5?= =?UTF-8?q?=D0=BB=D0=B8=D0=BA=D0=BE=D0=BC=20(23:50=20=D0=9A=D0=B8=D0=BF?= =?UTF-8?q?=D1=80,=20Codex),=20CY=20Fast=20Track=20Talks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- servers/rpi.md | 2 +- stacks/rpi-tg-export/README.md | 5 + stacks/rpi-tg-export/app/tgrag.py | 139 +++++++++++++++--- .../rpi-tg-export/systemd/tg-digest.service | 11 ++ stacks/rpi-tg-export/systemd/tg-digest.timer | 9 ++ 5 files changed, 148 insertions(+), 18 deletions(-) create mode 100644 stacks/rpi-tg-export/systemd/tg-digest.service create mode 100644 stacks/rpi-tg-export/systemd/tg-digest.timer diff --git a/servers/rpi.md b/servers/rpi.md index 425a558..36eb887 100644 --- a/servers/rpi.md +++ b/servers/rpi.md @@ -299,7 +299,7 @@ Docker CE 29.9 + Compose v5.6 из репо download.docker.com (в Debian ст - Проект `tg_export` (Telethon) перенесён с Mac: код, юниты и установка — [`stacks/rpi-tg-export/`](../stacks/rpi-tg-export/README.md). На малине — **второй аккаунт владельца**, докачка **только текста** раз в сутки (`tg-export.timer`, 04:30, `run --no-media --archived`), выгрузка в `/mnt/backup/tg/` (tgexport:astra 2750/640 — Astra читает). Старая выгрузка первого аккаунта (~14 ГБ) — на Mac, не переносилась (решение владельца). - Отдельный системный пользователь `tgexport` (без шелла): сессия `/var/lib/tg-export/tg.session` (600) = полный доступ к аккаунту — Astra её не видит; ключи приложения `/etc/tg-export/env` (root:tgexport 640, те же `api_id`/`api_hash`, что на Mac). Вход — владелец: `ssh -t rpi sudo -u tgexport tg-export login`. - Мониторинг — `backup-metrics backup_tg_export` → `BackupFailed`/`BackupStale` (>30 ч). ⚠️ Первый прогон может идти дольше суток из-за FloodWait. -- **Поиск и сводка** (с 2026-10-18): докачка каждый час (было раз в сутки 04:30) + индекс `/mnt/backup/tg/index.db` (FTS5 + эмбеддинги potion-multilingual-128M, fastembed в venv, модели `/opt/tg-export/models`); `tg search` для Astra, темы `tg topic`, сводка `tg-report.timer` 08:00 Asia/Nicosia через Codex → очередь outbox. Навык и правила — astra-bot `11dd4fc`. Служба `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; сводка включена, без тем и индекса молчит. +- **Поиск и сводка** (с 2026-10-18): докачка каждый час (было раз в сутки 04:30) + индекс `/mnt/backup/tg/index.db` (FTS5 + эмбеддинги potion-multilingual-128M, fastembed в venv, модели `/opt/tg-export/models`); `tg search` для Astra, темы `tg topic`, сводка `tg-report.timer` 08:00 Asia/Nicosia через Codex → очередь outbox. Навык и правила — astra-bot `11dd4fc`. Вечерний рекап группы целиком — `tg-digest.timer` 23:50 Asia/Nicosia (`tg digest`, сейчас @cyfasttrack). Служба `tg-embed` (astra) держит модель эмбеддингов в памяти для быстрых запросов (~6 с вместо ~40). Первая полная выгрузка 2026-10-18 19:49: 12 чатов, 303 тыс. сообщений с 2024-10-18, индекс 85 580 кусков, 1,2 ГБ на HDD. Подробно — README стека. Вход во второй аккаунт (@mikpokrovsky) — 2026-10-18; выгружаются только 6 выбранных владельцем чатов (`--only`, см. README стека). ⚠️ 2026-10-18 18:45–18:51 по ошибке шла выгрузка всех групп и каналов (успела только группа Python, 35 500 сообщений) — остановлена, данные удалены по решению владельца. ⚠️ Таймер выгрузки **выключен** до вступления владельца в @cyprusdiy, @cymedic и Cyprus Car – Chat; сводка включена, без тем и индекса молчит. - Доступ: тексты — только для владельца; Оле и Поле закрыто (правило в `codex/AGENTS.md` и скилл `tg`, astra-bot `27bf8e6`), как почта. ## Читалка books → Obsidian (с 2026-10-18) diff --git a/stacks/rpi-tg-export/README.md b/stacks/rpi-tg-export/README.md index 31e7146..6584da6 100644 --- a/stacks/rpi-tg-export/README.md +++ b/stacks/rpi-tg-export/README.md @@ -58,3 +58,8 @@ sudo systemctl enable --now tg-export.timer не лимит Claude читалки) → очередь `/var/lib/astra-google/outbox/` → `gcal remind` шлёт владельцу. ⚠️ Очередь шлёт одним `sendMessage` без разбиения: сводка режется на части ≤ 3800 знаков (иначе файл > 4096 застрял бы и заблокировал очередь). Тем нет или индекса нет — ничего не шлёт. `tg report --dry` — посмотреть. +- **Вечерний рекап группы целиком** (с 2026-10-18): `tg digest add @чат` (список — `/var/lib/tg-report/digests.json`), + `tg-digest.timer` 23:50 Asia/Nicosia → `tg digest run`: все куски чата **с прошлого рекапа** (не календарный день — + сообщения после последней ежечасной докачки уйдут в следующий рекап, без дыр), до 60 тыс. знаков (больше — последние), + Codex пишет темы дня и «Полезное» со ссылками → очередь outbox. Сейчас: CY Fast Track Talks (@cyfasttrack). + `tg digest run --dry --hours 24` — посмотреть. diff --git a/stacks/rpi-tg-export/app/tgrag.py b/stacks/rpi-tg-export/app/tgrag.py index b56effc..a661bbf 100644 --- a/stacks/rpi-tg-export/app/tgrag.py +++ b/stacks/rpi-tg-export/app/tgrag.py @@ -6,6 +6,8 @@ tg topic add "ТЕМА" [--query "что искать"] | list | del НОМЕР темы ежедневной сводки (query — поисковая формулировка, по умолчанию — сама тема) tg report [--hours 24] [--dry] сводка по темам за период → владельцу в Telegram (--dry — только напечатать) + tg digest add @ЧАТ | list | del @ЧАТ чаты для ежедневного рекапа целиком (всё за день, а не по темам) + tg digest run [--hours N] [--dry] рекап этих чатов с прошлого рекапа → владельцу в Telegram (таймер 23:50) tg stats сколько чатов/сообщений/кусков, когда обновлялось tg index (сервис tg-export, юзер tgexport) — дописать в индекс новые сообщения выгрузки tg serve (служба tg-embed, юзер astra) — держать модель в памяти, векторы запросов по unix-сокету @@ -377,6 +379,119 @@ REPORT_PROMPT = """Ты готовишь владельцу утреннюю с {data}""" +DIGEST_PROMPT = """Ты готовишь владельцу вечерний рекап Telegram-группы «{title}» за период {period}. +Ниже — вся переписка группы за этот период по кускам (время, ссылка на первое сообщение куска, текст «Автор: сообщение»). + +Сделай рекап по-русски, простым текстом для Telegram без Markdown: +- 3–8 главных тем дня, по каждой — 1–3 строки сути: о чём спорили, что выяснили, какие советы и ответы дали; +- отдельно «Полезное»: конкретика — места, контакты, цены, сроки, ссылки, документы, лайфхаки — с ссылкой на сообщение; +- если был важный вопрос без ответа — упомяни; +- флуд, приветствия, рекламу и оффтоп пропусти. Ничего не выдумывай, только по переписке. +Темы — строкой «▸ Тема», пункты — «• …». Без вступлений и выводов. Если переписки почти не было — одной строкой. + +{data}""" +DIGEST_MAX = 60_000 # знаков переписки в один запрос к Codex (больше — берём последние) + + +def digests(): + f = REPORT / "digests.json" + return json.loads(f.read_text()) if f.exists() else {"chats": [], "last": {}} + + +def save_digests(d): + REPORT.mkdir(parents=True, exist_ok=True) + (REPORT / "digests.json").write_text(json.dumps(d, ensure_ascii=False, indent=1)) + + +def find_chat(c, needle): + n = needle.lstrip("@").lower() + for r in c.execute("SELECT id, title, username FROM chat"): + if (r["username"] or "").lower() == n or str(r["id"]) == needle or n in (r["title"] or "").lower(): + return r + return None + + +def queue_text(text, tag): + OUTBOX.mkdir(parents=True, exist_ok=True) + parts, cur = [], "" + for para in text.split("\n"): + if len(cur) + len(para) + 1 > TG_MAX and cur: + parts.append(cur) + cur = "" + cur += para[:TG_MAX] + "\n" + if cur.strip(): + parts.append(cur) + old = os.umask(0o007) + try: + stamp = dt.datetime.now().strftime("%Y%m%d%H%M%S%f") + for i, p in enumerate(parts): + (OUTBOX / f"{stamp}-{tag}{i:02d}.txt").write_text(p.strip()) + finally: + os.umask(old) + return len(parts) + + +def cmd_digest(a): + d = digests() + if a.op == "list": + for x in d["chats"]: + last = d["last"].get(str(x["id"])) + print(f"{x['title']} (@{x.get('username') or x['id']})" + (f" — последний рекап {dt.datetime.fromtimestamp(last, TZ):%d.%m %H:%M}" if last else "")) + if not d["chats"]: + print("Чатов для рекапа нет. Добавить: tg digest add @чат") + return + if a.op in ("add", "del"): + c = db() + r = find_chat(c, a.chat) + if a.op == "add": + if not r: + raise SystemExit(f"{a.chat} нет в индексе — чат выгружается? (tg stats)") + if all(x["id"] != r["id"] for x in d["chats"]): + d["chats"].append({"id": r["id"], "title": r["title"], "username": r["username"]}) + print(f"Ежедневный рекап: {r['title']}") + else: + before = len(d["chats"]) + d["chats"] = [x for x in d["chats"] if not (r and x["id"] == r["id"]) and x.get("username") != a.chat.lstrip("@")] + print("Убран." if len(d["chats"]) < before else "Такого чата в рекапе нет.") + save_digests(d) + return + # run + if not d["chats"]: + print("Чатов для рекапа нет.") + return + if not DB.exists(): + print("Индекса ещё нет — рекап не делаю.") + return + c = db() + now = int(time.time()) + for x in d["chats"]: + since = now - a.hours * 3600 if a.hours else d["last"].get(str(x["id"]), now - 86400) + rows = c.execute("SELECT * FROM chunk WHERE chat_id=? AND ts1 > ? ORDER BY ts0", (x["id"], since)).fetchall() + chat = {"id": x["id"], "username": x.get("username")} + blocks, total = [], 0 + for r in reversed(rows): # не влезает — берём последние + b = f"── {dt.datetime.fromtimestamp(r['ts0'], TZ):%d.%m %H:%M} · {link(chat, r['first_id'])}\n{r['text']}" + if total + len(b) > DIGEST_MAX: + break + blocks.append(b) + total += len(b) + blocks.reverse() + period = f"{dt.datetime.fromtimestamp(since, TZ):%d.%m %H:%M} — {dt.datetime.fromtimestamp(now, TZ):%d.%m %H:%M}" + n_msgs = sum(r["n"] for r in rows) + if not rows: + text = f"🗒 {x['title']} за {period}: сообщений не было." + else: + body = codex(DIGEST_PROMPT.format(title=x["title"], period=period, data="\n\n".join(blocks))) + cut = f" (в рекапе последние {len(blocks)} из {len(rows)} кусков)" if len(blocks) < len(rows) else "" + text = f"🗒 {x['title']} — рекап за {period}, сообщений {n_msgs}{cut}\n\n{body}" + if a.dry: + print(text) + continue + print(f"{x['title']}: в очереди {queue_text(text, 'dg')} сообщ.") + d["last"][str(x["id"])] = now + save_digests(d) + + def cmd_report(a): t = topics() if not t: @@ -396,25 +511,10 @@ def cmd_report(a): if a.dry: print(text) return - OUTBOX.mkdir(parents=True, exist_ok=True) - parts, cur = [], "" - for para in text.split("\n"): - if len(cur) + len(para) + 1 > TG_MAX and cur: - parts.append(cur) - cur = "" - cur += para[:TG_MAX] + "\n" - if cur.strip(): - parts.append(cur) - old = os.umask(0o007) - try: - stamp = dt.datetime.now().strftime("%Y%m%d%H%M%S%f") - for i, p in enumerate(parts): - (OUTBOX / f"{stamp}-tg{i:02d}.txt").write_text(p.strip()) - finally: - os.umask(old) + n = queue_text(text, "tg") REPORT.mkdir(parents=True, exist_ok=True) (REPORT / "last-report.txt").write_text(text) - print(f"сводка поставлена в очередь: {len(parts)} сообщ.") + print(f"сводка поставлена в очередь: {n} сообщ.") def cmd_serve(a): @@ -451,6 +551,11 @@ def main(): r = ts.add_parser("del"); r.add_argument("num") ts.add_parser("list") s = sub.add_parser("report"); s.add_argument("--hours", type=int, default=24); s.add_argument("--dry", action="store_true") + s = sub.add_parser("digest"); ds = s.add_subparsers(dest="op", required=True) + r = ds.add_parser("add"); r.add_argument("chat") + r = ds.add_parser("del"); r.add_argument("chat") + ds.add_parser("list") + r = ds.add_parser("run"); r.add_argument("--hours", type=int, default=0); r.add_argument("--dry", action="store_true") sub.add_parser("stats") s = sub.add_parser("index"); s.add_argument("--quiet", action="store_true") sub.add_parser("serve") diff --git a/stacks/rpi-tg-export/systemd/tg-digest.service b/stacks/rpi-tg-export/systemd/tg-digest.service new file mode 100644 index 0000000..7598f62 --- /dev/null +++ b/stacks/rpi-tg-export/systemd/tg-digest.service @@ -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 diff --git a/stacks/rpi-tg-export/systemd/tg-digest.timer b/stacks/rpi-tg-export/systemd/tg-digest.timer new file mode 100644 index 0000000..275acb5 --- /dev/null +++ b/stacks/rpi-tg-export/systemd/tg-digest.timer @@ -0,0 +1,9 @@ +[Unit] +Description=Telegram: рекап групп каждый день в 23:50 (Кипр) + +[Timer] +OnCalendar=*-*-* 23:50 Asia/Nicosia +Persistent=true + +[Install] +WantedBy=timers.target