rpi: tg digest — вечерний рекап группы целиком (23:50 Кипр, Codex), CY Fast Track Talks

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
gilfanovr
2026-10-18 20:14:31 +03:00
co-authored by Claude Opus 5.5
parent dfe0fad728
commit 15a9e1d2de
5 changed files with 148 additions and 18 deletions
+1 -1
View File
@@ -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)
+5
View File
@@ -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` — посмотреть.
+122 -17
View File
@@ -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")
@@ -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