From 78ba888bb9e522c3a8dad4197797682e577ba56d Mon Sep 17 00:00:00 2001 From: simbiot-harness Date: Tue, 29 Sep 2026 21:04:31 +0000 Subject: [PATCH] =?UTF-8?q?feat(tg):=20TG-visibility=20=E2=80=94=20=D1=83?= =?UTF-8?q?=D0=B2=D0=B5=D0=B4=D0=BE=D0=BC=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F=20?= =?UTF-8?q?=D0=BE=20=D0=B7=D0=B0=D0=BD=D1=8F=D1=82=D0=BE=D1=81=D1=82=D0=B8?= =?UTF-8?q?=20=D1=83=D1=80=D0=BE=D0=B2=D0=BD=D0=B5=D0=B9=20+=20=D1=81?= =?UTF-8?q?=D1=80=D0=B5=D0=B7=20=D0=BB=D0=B5=D1=81=D1=82=D0=BD=D0=B8=D1=86?= =?UTF-8?q?=D1=8B=20=D0=BF=D0=BE=20=D0=B7=D0=B0=D0=BF=D1=80=D0=BE=D1=81?= =?UTF-8?q?=D1=83=20(t-260929170039-aad557)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - tg_inbox.notify_chat(): отправка в чат харнеса без входящего-родителя (чат = последний MessageReceived от Alex) - tg_inbox.ladder_snapshot(): срез лестницы — занятость/тики/ошибки уровней, готово/в работе, открытые эскалации; read-only через run_projections() - scheduler._notify_busy(): уровень сообщает в TG о взятии задачи (анти-спам 15м на уровень) — TaskClaimed в scheduler.run_level_tick - cli.py: команда ladder + tg-notify; /лестница|/ladder|/статус|/status в tg-reply заменяется на срез лестницы — запрос из TG самодостаточен - guard в __main__ tg_inbox: единственный легитимный поллер — systemd-сервис (раньше python -m harness.tg_inbox с любыми аргументами поднимал конкурирующего поллера → 409 Conflict в легитимном) --- cli.py | 17 ++++++++++++++ harness/tg_inbox.py | 56 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+) diff --git a/cli.py b/cli.py index 1191a3e..7eec1ce 100644 --- a/cli.py +++ b/cli.py @@ -35,6 +35,10 @@ def main(): p_tgr.add_argument("msg_id", help="id входящего (m-NNN)") p_tgr.add_argument("text") + p_lad = sub.add_parser("ladder", help="срез лестницы: уровни, занятость, задачи, эскалации") + p_not = sub.add_parser("tg-notify", help="отправить сообщение в чат харнеса (уведомление)") + p_not.add_argument("text") + p_esc = sub.add_parser("escalate", help="эскалация важного на уровень выше (min→hour→day)") p_esc.add_argument("reason") p_esc.add_argument("-f", "--from-level", default="min", choices=["min", "hour"], @@ -85,8 +89,21 @@ def main(): elif args.cmd == "tg-reply": es.init_db() from harness import tg_inbox + low = args.text.strip().lower() + if low in ("/лестница", "/ladder", "/статус", "/status", "лестница", "status"): + # срез лестницы по запросу из TG — видимостью управляет сам харнес + args.text = "📊 " + tg_inbox.ladder_snapshot() mid = tg_inbox.send_reply(args.msg_id, args.text) print(f"OK: ответ на {mid} отправлен") + elif args.cmd == "ladder": + es.init_db() + from harness import tg_inbox + print(tg_inbox.ladder_snapshot()) + elif args.cmd == "tg-notify": + es.init_db() + from harness import tg_inbox + r = tg_inbox.notify_chat(args.text) + print("OK: уведомление отправлено" if r else "SKIP: некому отправить (чат не известен)") elif args.cmd == "escalate": es.init_db() eid, to_level = commands.raise_escalation( diff --git a/harness/tg_inbox.py b/harness/tg_inbox.py index 3ae54a5..b6434ef 100644 --- a/harness/tg_inbox.py +++ b/harness/tg_inbox.py @@ -9,6 +9,7 @@ getUpdates offset=last+1. Идемпотентность: update_id → aggregat """ import json import os +import sys import time import urllib.request import urllib.parse @@ -153,5 +154,60 @@ def poll_loop(): time.sleep(5) +def notify_chat(text: str): + """Отправить сообщение в чат харнеса (без входящего-родителя — уведомления). + + Чат берём из последнего входящего MessageReceived (это личка Алекса); + если входящих ещё не было — тихо пропускаем (некому показывать). + """ + _load_env() + if not TG_TOKEN: + return None + model = es.run_projections() + inbox = [m for m in (model.get("inbox") or {}).values() + if m.get("chat_id") and not m.get("ignored")] + if not inbox: + return None # некому: чат ещё не известен + chat_id = sorted(inbox, key=lambda m: m.get("ts") or "")[-1]["chat_id"] + try: + return send_message(chat_id, text) + except Exception: + return None # уведомление — best effort, не валить тик + + +def ladder_snapshot() -> str: + """Срез лестницы для Telegram: уровни, занятость, задачи, эскалации.""" + model = es.run_projections() + cs = model.get("cycle_state") or {} + order = ["sec", "min", "hour", "day"] + lines = [] + for name in order: + st = cs.get(name) or {} + busy = "🔧 занят" if st.get("busy") else "ожидает" + lines.append(f"{'🔹' if name != 'sec' else '⚡'} {name}: {busy}, тиков {st.get('ticks', 0)}" + + (f", ошибок {st['errors']}" if st.get("errors") else "")) + tasks = list((model.get("tasks") or {}).values()) + tasks.sort(key=lambda t: t.get("created") or "", reverse=True) + board = [t for t in tasks if t.get("status") in ("ready", "running")] + lines.append(f"📋 готово/в работе: {sum(1 for t in tasks if t['status'] == 'ready')}/" + f"{sum(1 for t in tasks if t['status'] == 'running')}") + for t in board[:8]: + lines.append(f" [{t['size']}] {t['status']}: {t['title'][:60]}") + open_esc = [e for e in (model.get("escalations") or {}).values() + if isinstance(e, dict) and e.get("status") == "open"] + lines.append(f"🚨 открытых эскалаций: {len(open_esc)}") + for e in open_esc[:5]: + lines.append(f" [{e.get('severity', 'info')}] {e.get('reason', '')[:80]}") + return "\n".join(lines) + + if __name__ == "__main__": + # Единственный легитимный поллер — systemd-сервис simbiot-harness-tg. + # `python -m harness.tg_inbox` с любыми аргументами (--help, list, …) + # раньше поднимал второго поллера → 409 Conflict в легитимном. + if len(sys.argv) > 1: + print("usage: python -m harness.tg_inbox # long-poll поллер (systemd)") + print(" cli.py ladder # срез лестницы (текст)") + print(" cli.py tg-notify # отправка в чат харнеса") + sys.exit(0) poll_loop() \ No newline at end of file