feat(tg-cmd): без-LLM команды бота /лестница /статус /задачи /помощь — мгновенные ответы сек-рефлексом + setMyCommands (t-260929210755-9bf6a6)
- harness/tg_commands.py: реестр команд, парсер (кириллица/транслит/@бот/без слэша), хендлеры из read model, answer_pending (идемпотентно, cooldown 60с при лежащем TG), setMyCommands с фолбэком кириллица→транслит (Bot API латиницу только) - scheduler.py: сек-тик отвечает на команды без LLM (payload.commands), build_context НЕ показывает команды мин-уровню (гонки/лежащий TG не тратят LLM) - tg_inbox.py: send_message/send_reply принимают короткий timeout для рефлекса; поллер при старте авторегистрирует команды бота - cli.py: tg-commands --register/--list
This commit is contained in:
parent
7f4db9f085
commit
76aa204f2b
8 changed files with 330 additions and 6 deletions
19
README.md
19
README.md
|
|
@ -33,6 +33,7 @@ Strands Harness SDK как исполнительное ядро, модели
|
||||||
- `harness/isolated_runner.py` + `_llm_worker.py` — LLM-тик в сабпроцессе
|
- `harness/isolated_runner.py` + `_llm_worker.py` — LLM-тик в сабпроцессе
|
||||||
(глобальный asyncio-рантайм Strands не переживает 2+ `create_harness`)
|
(глобальный asyncio-рантайм Strands не переживает 2+ `create_harness`)
|
||||||
- `harness/commands.py` — command side
|
- `harness/commands.py` — command side
|
||||||
|
- `harness/tg_commands.py` — без-LLM команды бота (сек-рефлекс)
|
||||||
- `cli.py` — init/task/task-await/tasks/status/events/requeue
|
- `cli.py` — init/task/task-await/tasks/status/events/requeue
|
||||||
|
|
||||||
## CLI
|
## CLI
|
||||||
|
|
@ -87,6 +88,24 @@ journalctl -u simbiot-harness -f
|
||||||
отвечает `cli.py tg-reply` (Telegram-отправка + `ReplySent` закрывает входящее).
|
отвечает `cli.py tg-reply` (Telegram-отправка + `ReplySent` закрывает входящее).
|
||||||
Белый список чатов в `.env` (`SIMBIOT_TG_ALLOWED`), чужие → `MessageIgnored`.
|
Белый список чатов в `.env` (`SIMBIOT_TG_ALLOWED`), чужие → `MessageIgnored`.
|
||||||
|
|
||||||
|
## Без-LLM команды бота (t-260929210755)
|
||||||
|
|
||||||
|
`/лестница` `/статус` `/задачи` `/помощь` — мгновенные ответы из read model:
|
||||||
|
их закрывает **секундный рефлекс** (`harness/tg_commands.py` в `_reflex_tick`),
|
||||||
|
минутный LLM-контекст на команды не тратится. Алиасы (латиница/транслит,
|
||||||
|
`/cmd@bot`, одно слово без слэша) понимает парсер; список в чате —
|
||||||
|
`setMyCommands` (кириллица, фолбэк — транслит), регистрируется стартом
|
||||||
|
tg-поллера или вручную:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./venv/bin/python cli.py tg-commands --register # setMyCommands
|
||||||
|
./venv/bin/python cli.py tg-commands --list # что у бота сейчас
|
||||||
|
```
|
||||||
|
|
||||||
|
События: `CommandHandled` (команда закрыта рефлексом) + `ReplySent`;
|
||||||
|
`build_context` не показывает команды мин-уровню — даже без ответа
|
||||||
|
(гонка на границе тика / лежащий TG не заставят минуту тратить LLM).
|
||||||
|
|
||||||
## Дашборд задач
|
## Дашборд задач
|
||||||
|
|
||||||
Веб-страница со всеми задачами (статусы/размеры/уровни) + лестница циклов +
|
Веб-страница со всеми задачами (статусы/размеры/уровни) + лестница циклов +
|
||||||
|
|
|
||||||
17
cli.py
17
cli.py
|
|
@ -48,6 +48,10 @@ def main():
|
||||||
p_lad = sub.add_parser("ladder", help="срез лестницы: уровни, занятость, задачи, эскалации")
|
p_lad = sub.add_parser("ladder", help="срез лестницы: уровни, занятость, задачи, эскалации")
|
||||||
p_not = sub.add_parser("tg-notify", help="отправить сообщение в чат харнеса (уведомление)")
|
p_not = sub.add_parser("tg-notify", help="отправить сообщение в чат харнеса (уведомление)")
|
||||||
p_not.add_argument("text")
|
p_not.add_argument("text")
|
||||||
|
p_tgc = sub.add_parser("tg-commands", help="команды бота: setMyCommands + список")
|
||||||
|
p_tgc.add_argument("--register", action="store_true",
|
||||||
|
help="(re)зарегистрировать setMyCommands (кириллица, фолбэк — транслит)")
|
||||||
|
p_tgc.add_argument("--list", action="store_true", help="показать текущие команды бота")
|
||||||
|
|
||||||
p_esc = sub.add_parser("escalate", help="эскалация важного на уровень выше (min→hour→day)")
|
p_esc = sub.add_parser("escalate", help="эскалация важного на уровень выше (min→hour→day)")
|
||||||
p_esc.add_argument("reason")
|
p_esc.add_argument("reason")
|
||||||
|
|
@ -152,6 +156,19 @@ def main():
|
||||||
from harness import tg_inbox
|
from harness import tg_inbox
|
||||||
r = tg_inbox.notify_chat(args.text)
|
r = tg_inbox.notify_chat(args.text)
|
||||||
print("OK: уведомление отправлено" if r else "SKIP: некому отправить (чат не известен)")
|
print("OK: уведомление отправлено" if r else "SKIP: некому отправить (чат не известен)")
|
||||||
|
elif args.cmd == "tg-commands":
|
||||||
|
es.init_db()
|
||||||
|
from harness import tg_commands
|
||||||
|
if args.register:
|
||||||
|
mode = tg_commands.register_bot_commands()
|
||||||
|
print(f"OK: команды бота зарегистрированы ({mode})" if mode
|
||||||
|
else "ERROR: setMyCommands не прошёл (нет токена / API недоступен)")
|
||||||
|
if args.list or not args.register:
|
||||||
|
cmds = tg_commands.get_my_commands()
|
||||||
|
if not cmds:
|
||||||
|
print("(у бота нет зарегистрированных команд)")
|
||||||
|
for name, desc in cmds:
|
||||||
|
print(f"/{name} — {desc}")
|
||||||
elif args.cmd == "escalate":
|
elif args.cmd == "escalate":
|
||||||
es.init_db()
|
es.init_db()
|
||||||
eid, to_level = commands.raise_escalation(
|
eid, to_level = commands.raise_escalation(
|
||||||
|
|
|
||||||
0
events.db
Normal file
0
events.db
Normal file
0
harness/events.db
Normal file
0
harness/events.db
Normal file
|
|
@ -123,8 +123,14 @@ def build_context(level_cfg, model: dict, task=None):
|
||||||
# Голосовое с пустой расшифровкой (тишина/отказ) тоже показываем —
|
# Голосовое с пустой расшифровкой (тишина/отказ) тоже показываем —
|
||||||
# минутный уровень должен знать, что голосовое пришло, но текста нет,
|
# минутный уровень должен знать, что голосовое пришло, но текста нет,
|
||||||
# и ответить «не расслышал», а не делать вид, что сообщения не было.
|
# и ответить «не расслышал», а не делать вид, что сообщения не было.
|
||||||
|
# Команды бота (/лестница и др.) — НЕ здесь: их мгновенно закрывает
|
||||||
|
# сек-рефлекс (t-260929210755), мин-LLM на них не тратится. Фильтруем
|
||||||
|
# даже без ответа — иначе гонка на границе тика (команда пришла,
|
||||||
|
# сек ещё не отвечал) или лежащий TG заставляли минуту отвечать LLM.
|
||||||
|
from . import tg_commands
|
||||||
inbox = [m for m in (model.get("inbox") or {}).values()
|
inbox = [m for m in (model.get("inbox") or {}).values()
|
||||||
if not m.get("replied") and not m.get("ignored")
|
if not m.get("replied") and not m.get("ignored")
|
||||||
|
and not tg_commands.parse(m.get("text") or "")
|
||||||
and (m.get("text") or m.get("kind") in ("voice", "audio", "video_note"))]
|
and (m.get("text") or m.get("kind") in ("voice", "audio", "video_note"))]
|
||||||
if inbox:
|
if inbox:
|
||||||
lines = []
|
lines = []
|
||||||
|
|
@ -292,7 +298,12 @@ def process_level(level_cfg, model: dict):
|
||||||
|
|
||||||
|
|
||||||
def _reflex_tick(level_cfg, seq: int, model: dict):
|
def _reflex_tick(level_cfg, seq: int, model: dict):
|
||||||
"""Секундный рефлекс: телеметрия + алерты, без LLM."""
|
"""Секундный рефлекс: телеметрия + алерты + без-LLM команды, без LLM.
|
||||||
|
|
||||||
|
Команды бота (/лестница, /статус, /задачи, /помощь) — мгновенные
|
||||||
|
ответы из read model (t-260929210755): мин-уровень на них не
|
||||||
|
тратится, ReplySent закрывает входящее сразу после отправки.
|
||||||
|
"""
|
||||||
import os
|
import os
|
||||||
load = None
|
load = None
|
||||||
try:
|
try:
|
||||||
|
|
@ -306,6 +317,17 @@ def _reflex_tick(level_cfg, seq: int, model: dict):
|
||||||
thr = (model.get("policy") or {}).get("cpu_alert_threshold", 8)
|
thr = (model.get("policy") or {}).get("cpu_alert_threshold", 8)
|
||||||
if load is not None and load >= thr:
|
if load is not None and load >= thr:
|
||||||
payload["alert"] = f"load {load:.2f} >= {thr}"
|
payload["alert"] = f"load {load:.2f} >= {thr}"
|
||||||
|
# без-LLM команды: отвечаем на входящие-команды без ответа
|
||||||
|
try:
|
||||||
|
from . import tg_commands
|
||||||
|
handled = tg_commands.answer_pending(model)
|
||||||
|
if handled:
|
||||||
|
for mid, cmd in handled:
|
||||||
|
_log(f"tg-cmd: {mid} /{cmd} → отвечено (сек-рефлекс)")
|
||||||
|
payload["commands"] = [c for _, c in handled]
|
||||||
|
except Exception as e:
|
||||||
|
# команды — часть рефлекса, но не должны валить телеметрию
|
||||||
|
print(f"[tg-cmd] ответ на команды не удался: {e}", flush=True)
|
||||||
es.append_event("cycle", f"reflex:{seq}", "ReflexObserved", payload)
|
es.append_event("cycle", f"reflex:{seq}", "ReflexObserved", payload)
|
||||||
return payload, None
|
return payload, None
|
||||||
|
|
||||||
|
|
|
||||||
244
harness/tg_commands.py
Normal file
244
harness/tg_commands.py
Normal file
|
|
@ -0,0 +1,244 @@
|
||||||
|
"""Simbiot Harness — без-LLM команды бота (/лестница, /статус, /задачи, /помощь).
|
||||||
|
|
||||||
|
Секундный уровень (рефлекс, без LLM) отвечает на команды мгновенно —
|
||||||
|
минутный LLM-контекст на них не тратится (t-260929210755):
|
||||||
|
answer_pending() в _reflex_tick находит в инбоксе входящие-команды,
|
||||||
|
хендлер собирает текст из read model, send_reply отправляет.
|
||||||
|
ReplySent закрывает входящее — мин-уровень команду больше не видит
|
||||||
|
(build_context их дополнительно фильтрует на случай гонки).
|
||||||
|
Не отправилось (TG недоступен) — следующий сек-тик попробует снова:
|
||||||
|
ReplySent пишется только после попытки отправки, команда остаётся «без ответа».
|
||||||
|
|
||||||
|
Регистрация в чате: setMyCommands — кириллические имена; если Bot API
|
||||||
|
кириллицу не принимает, фолбэк на транслит. Вызывается стартом tg-поллера
|
||||||
|
и CLI `cli.py tg-commands`. Алиасы парсер понимает независимо от того,
|
||||||
|
какие имена зарегистрированы.
|
||||||
|
"""
|
||||||
|
import html
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from . import es
|
||||||
|
|
||||||
|
# cooldown после неудачной отправки: сек-тик пробует команды раз в минуту,
|
||||||
|
# а не каждую секунду — лежащий TG не вешает рефлекс на 15с-таймаутах
|
||||||
|
_FAIL_COOLDOWN = 60
|
||||||
|
_last_fail_ts = 0.0
|
||||||
|
|
||||||
|
# каноническое имя → алиасы (регистронезависимые, без слэша)
|
||||||
|
_ALIASES = {
|
||||||
|
"лестница": ("лестница", "ladder", "lestnica"),
|
||||||
|
"статус": ("статус", "status"),
|
||||||
|
"задачи": ("задачи", "tasks", "zadachi"),
|
||||||
|
"помощь": ("помощь", "help", "pomoshch", "start", "начать", "команды", "commands"),
|
||||||
|
}
|
||||||
|
_ALIAS_TO_CMD = {}
|
||||||
|
for _c, _aliases in _ALIASES.items():
|
||||||
|
for _a in _aliases:
|
||||||
|
_ALIAS_TO_CMD[_a] = _c
|
||||||
|
|
||||||
|
_DESCRIPTIONS = {
|
||||||
|
"лестница": "Срез лестницы: уровни, занятость, задачи, эскалации",
|
||||||
|
"статус": "Сводка харнеса: тики, задачи, спенд",
|
||||||
|
"задачи": "Доска задач: готово и в работе",
|
||||||
|
"помощь": "Что я умею и как со мной говорить",
|
||||||
|
}
|
||||||
|
# транслит-фолбэк, если Bot API не примет кириллицу в именах команд
|
||||||
|
_LATIN = {"лестница": "lestnica", "статус": "status", "задачи": "zadachi", "помощь": "pomoshch"}
|
||||||
|
|
||||||
|
|
||||||
|
def parse(text) -> "str | None":
|
||||||
|
"""Сообщение — команда? Возвращает каноническое имя или None.
|
||||||
|
|
||||||
|
Команда: "/имя" (первый токен; @бот отбрасывается) или ОДИНОЧНОЕ
|
||||||
|
слово-алиас без слэша («лестница»). Фраза с пробелами без слэша —
|
||||||
|
не команда (обычная речь, её слышит минутный LLM-уровень).
|
||||||
|
"""
|
||||||
|
t = (text or "").strip()
|
||||||
|
if not t:
|
||||||
|
return None
|
||||||
|
if t.startswith("/"):
|
||||||
|
tok = t.split()[0].lstrip("/")
|
||||||
|
if "@" in tok: # /cmd@botname — адресация боту
|
||||||
|
tok = tok.split("@", 1)[0]
|
||||||
|
else:
|
||||||
|
if len(t.split()) > 1:
|
||||||
|
return None
|
||||||
|
tok = t
|
||||||
|
tok = tok.strip(".,!?;:«»\"'()").lower()
|
||||||
|
return _ALIAS_TO_CMD.get(tok)
|
||||||
|
|
||||||
|
|
||||||
|
def _esc(s) -> str:
|
||||||
|
"""TG-отправка идёт с parse_mode=HTML — динамику экранируем."""
|
||||||
|
return html.escape(str(s or ""), quote=False)
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_ladder() -> str:
|
||||||
|
from . import tg_inbox
|
||||||
|
return "⚡️ Срез лестницы:\n\n" + _esc(tg_inbox.ladder_snapshot())
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_status() -> str:
|
||||||
|
model = es.run_projections()
|
||||||
|
cs = model.get("cycle_state") or {}
|
||||||
|
tasks = list((model.get("tasks") or {}).values())
|
||||||
|
counts = {}
|
||||||
|
for t in tasks:
|
||||||
|
counts[t.get("status") or "?"] = counts.get(t.get("status") or "?", 0) + 1
|
||||||
|
spend = model.get("spend") or []
|
||||||
|
tokens = sum(int(e.get("prompt_tokens") or 0) + int(e.get("completion_tokens") or 0)
|
||||||
|
for e in spend)
|
||||||
|
open_esc = sum(1 for e in (model.get("escalations") or {}).values()
|
||||||
|
if isinstance(e, dict) and e.get("status") == "open")
|
||||||
|
lines = [f"📊 Статус харнеса · {datetime.now(timezone.utc).strftime('%d.%m %H:%M')} UTC"]
|
||||||
|
for name in ("sec", "min", "hour", "day"):
|
||||||
|
st = cs.get(name) or {}
|
||||||
|
err = f", ошибок {st['errors']}" if st.get("errors") else ""
|
||||||
|
lines.append(f" {name}: тиков {st.get('ticks', 0)}{err}")
|
||||||
|
parts = [f"{k} {counts[k]}" for k in ("ready", "running", "done", "failed") if counts.get(k)]
|
||||||
|
lines.append("📋 задачи: " + (" · ".join(parts) if parts else "нет"))
|
||||||
|
lines.append(f"🚨 эскалаций открытых: {open_esc}")
|
||||||
|
lines.append(f"💰 спенд (последние {len(spend)} тиков): ~{tokens:,} токенов".replace(",", " "))
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_tasks() -> str:
|
||||||
|
model = es.run_projections()
|
||||||
|
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")]
|
||||||
|
done = sum(1 for t in tasks if t.get("status") == "done")
|
||||||
|
lines = [f"📋 Доска: готово {sum(1 for t in tasks if t.get('status') == 'ready')}"
|
||||||
|
f" · в работе {sum(1 for t in tasks if t.get('status') == 'running')}"
|
||||||
|
f" · сделано {done}"]
|
||||||
|
for t in board[:12]:
|
||||||
|
lines.append(f" [{t.get('size', '?')}] {_esc((t.get('title') or '')[:70])}")
|
||||||
|
if len(board) > 12:
|
||||||
|
lines.append(f" … ещё {len(board) - 12}")
|
||||||
|
lines.append("Выполняют часовой/дневной уровни; детали — на дашборде.")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_help() -> str:
|
||||||
|
lines = ["🤖 Simbiot — команды (мгновенные, без LLM):"]
|
||||||
|
for c, d in _DESCRIPTIONS.items():
|
||||||
|
lines.append(f" /{_LATIN[c]} (или /{c}) — {d}")
|
||||||
|
lines.append("")
|
||||||
|
lines.append("Обычный текст и голосовые уходят минутному пульсу (LLM) — отвечу по смыслу.")
|
||||||
|
lines.append("Большую просьбу можно заказать задачей: результат придёт в чат сам, когда будет готов.")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
_HANDLERS = {
|
||||||
|
"лестница": cmd_ladder,
|
||||||
|
"статус": cmd_status,
|
||||||
|
"задачи": cmd_tasks,
|
||||||
|
"помощь": cmd_help,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def answer(msg_id: str, cmd: str) -> str:
|
||||||
|
"""Собирает ответ команды и отправляет. ReplySent закрывает входящее.
|
||||||
|
|
||||||
|
Команда — не задача и не LLM-тик: это рефлекс. CommandHandled (после
|
||||||
|
успешной отправки) — только наблюдаемость: видно, что входящее закрыто
|
||||||
|
именно командой, а не минутным триажем.
|
||||||
|
"""
|
||||||
|
from . import tg_inbox
|
||||||
|
text = _HANDLERS[cmd]()
|
||||||
|
# короткий таймаут: сек-тик не должен висеть на TG дольше пары секунд
|
||||||
|
tg_inbox.send_reply(msg_id, text, timeout=15)
|
||||||
|
try:
|
||||||
|
es.append_event("inbox", msg_id, "CommandHandled", {
|
||||||
|
"command": cmd, "level": "sec", "handler": "tg_commands",
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
pass # событие — наблюдаемость; ReplySent уже закрыл входящее
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def answer_pending(model: "dict | None" = None):
|
||||||
|
"""Отвечает на все входящие-команды без ответа. [(msg_id, cmd), …].
|
||||||
|
|
||||||
|
Идемпотентно: replied-сообщения пропускаются (проекция свежая —
|
||||||
|
send_reply сам перепроверяет по стору и кинет ValueError при гонке
|
||||||
|
с минутным уровнем — это не ошибка, просто пропускаем).
|
||||||
|
"""
|
||||||
|
if model is None:
|
||||||
|
model = es.run_projections()
|
||||||
|
# есть ли вообще команды-кандидаты: если нет — cooldown не проверяем
|
||||||
|
pending = [m for m in (model.get("inbox") or {}).values()
|
||||||
|
if isinstance(m, dict) and not m.get("replied") and not m.get("ignored")
|
||||||
|
and parse(m.get("text") or "")]
|
||||||
|
if not pending:
|
||||||
|
return []
|
||||||
|
global _last_fail_ts
|
||||||
|
if time.time() - _last_fail_ts < _FAIL_COOLDOWN:
|
||||||
|
return [] # недавно не смогли отправить — ждём, рефлекс не висит на TG
|
||||||
|
handled = []
|
||||||
|
for m in pending:
|
||||||
|
mid = m.get("id") or (f"m-{m.get('update_id')}" if m.get("update_id") else None)
|
||||||
|
if not mid:
|
||||||
|
continue
|
||||||
|
cmd = parse(m.get("text") or "")
|
||||||
|
try:
|
||||||
|
answer(mid, cmd)
|
||||||
|
handled.append((mid, cmd))
|
||||||
|
except ValueError:
|
||||||
|
pass # уже отвечено (гонка) — не ошибка
|
||||||
|
except Exception as e:
|
||||||
|
# TG недоступен и пр. — НЕ отвечено; ставим cooldown и повторим
|
||||||
|
# не раньше чем через минуту (рефлекс тикает каждую секунду)
|
||||||
|
_last_fail_ts = time.time()
|
||||||
|
print(f"[tg-cmd] {mid} /{cmd} не отвечено: {e} "
|
||||||
|
f"(cooldown {_FAIL_COOLDOWN}с)", flush=True)
|
||||||
|
return handled
|
||||||
|
|
||||||
|
|
||||||
|
# ────────────── регистрация в чате (setMyCommands) ──────────────
|
||||||
|
|
||||||
|
|
||||||
|
def commands_spec(latin: bool = False) -> list:
|
||||||
|
return [{"command": (_LATIN[c] if latin else c), "description": d}
|
||||||
|
for c, d in _DESCRIPTIONS.items()]
|
||||||
|
|
||||||
|
|
||||||
|
def register_bot_commands():
|
||||||
|
"""setMyCommands: сначала кириллица, при отказе — транслит.
|
||||||
|
|
||||||
|
Возвращает 'cyrillic' | 'latin' | None (нет токена/не удалось).
|
||||||
|
Повторный вызов идемпотентен — перезапишет список тем же содержимым.
|
||||||
|
"""
|
||||||
|
from . import tg_inbox
|
||||||
|
tg_inbox._load_env()
|
||||||
|
if not tg_inbox.TG_TOKEN:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
r = tg_inbox._api("setMyCommands",
|
||||||
|
{"commands": json.dumps(commands_spec(), ensure_ascii=False)})
|
||||||
|
if r.get("ok"):
|
||||||
|
return "cyrillic"
|
||||||
|
raise RuntimeError(str(r)[:200])
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[tg-cmd] setMyCommands: кириллица не принята ({e}) — пробую транслит", flush=True)
|
||||||
|
try:
|
||||||
|
r = tg_inbox._api("setMyCommands",
|
||||||
|
{"commands": json.dumps(commands_spec(latin=True), ensure_ascii=False)})
|
||||||
|
return "latin" if r.get("ok") else None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_my_commands() -> list:
|
||||||
|
"""Текущий список команд у бота ([(name, description), …])."""
|
||||||
|
from . import tg_inbox
|
||||||
|
tg_inbox._load_env()
|
||||||
|
if not tg_inbox.TG_TOKEN:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
r = tg_inbox._api("getMyCommands", {})
|
||||||
|
return [(c.get("command"), c.get("description")) for c in r.get("result", [])]
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
0
harness/tg_inbox.db
Normal file
0
harness/tg_inbox.db
Normal file
|
|
@ -13,6 +13,7 @@ import sys
|
||||||
import time
|
import time
|
||||||
import urllib.request
|
import urllib.request
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
|
import urllib.error
|
||||||
|
|
||||||
from . import es
|
from . import es
|
||||||
|
|
||||||
|
|
@ -43,13 +44,25 @@ def _api(method: str, params: dict, timeout: int = 35):
|
||||||
return json.loads(r.read().decode())
|
return json.loads(r.read().decode())
|
||||||
|
|
||||||
|
|
||||||
def send_message(chat_id, text: str, reply_to=None):
|
def send_message(chat_id, text: str, reply_to=None, timeout: int = 35):
|
||||||
"""Отправка сообщения (используется tg-reply и алертами)."""
|
"""Отправка сообщения (используется tg-reply и алертами).
|
||||||
|
|
||||||
|
timeout: сек-тик (без-LLM команды) передаёт короткий — рефлекс не
|
||||||
|
должен висеть на TG дольше пары секунд.
|
||||||
|
"""
|
||||||
_load_env()
|
_load_env()
|
||||||
params = {"chat_id": chat_id, "text": text, "parse_mode": "HTML"}
|
params = {"chat_id": chat_id, "text": text, "parse_mode": "HTML"}
|
||||||
if reply_to:
|
if reply_to:
|
||||||
params["reply_to_message_id"] = reply_to
|
params["reply_to_message_id"] = reply_to
|
||||||
return _api("sendMessage", params)
|
try:
|
||||||
|
return _api("sendMessage", params, timeout=timeout)
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
# 400 от кривого HTML (незакрытый тег, «a < b» в тексте) — шлём как plain text.
|
||||||
|
# Иначе ответ/доставка молча теряются: исключение глотается на верхнем уровне.
|
||||||
|
if e.code == 400:
|
||||||
|
params.pop("parse_mode", None)
|
||||||
|
return _api("sendMessage", params, timeout=timeout)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
def receive_message(update: dict):
|
def receive_message(update: dict):
|
||||||
|
|
@ -101,12 +114,13 @@ def receive_message(update: dict):
|
||||||
return f"m-{update['update_id']}"
|
return f"m-{update['update_id']}"
|
||||||
|
|
||||||
|
|
||||||
def send_reply(msg_id: str, text: str):
|
def send_reply(msg_id: str, text: str, timeout: int = 35):
|
||||||
"""Ответ на входящее: Telegram-отправка + событие ReplySent (закрывает входящее).
|
"""Ответ на входящее: Telegram-отправка + событие ReplySent (закрывает входящее).
|
||||||
|
|
||||||
Перед отправкой шлём sendChatAction typing — в TG видно «печатает…».
|
Перед отправкой шлём sendChatAction typing — в TG видно «печатает…».
|
||||||
sendChatAction живёт ~5с и НЕ обновляется сам — этого достаточно для
|
sendChatAction живёт ~5с и НЕ обновляется сам — этого достаточно для
|
||||||
минутного пульса (типичный лаг ответа < 5с после начала отправки).
|
минутного пульса (типичный лаг ответа < 5с после начала отправки).
|
||||||
|
timeout — как в send_message (короткий для сек-рефлекса).
|
||||||
"""
|
"""
|
||||||
model = es.run_projections()
|
model = es.run_projections()
|
||||||
m = (model.get("inbox") or {}).get(msg_id)
|
m = (model.get("inbox") or {}).get(msg_id)
|
||||||
|
|
@ -118,7 +132,7 @@ def send_reply(msg_id: str, text: str):
|
||||||
_api("sendChatAction", {"chat_id": m["chat_id"], "action": "typing"}, timeout=10)
|
_api("sendChatAction", {"chat_id": m["chat_id"], "action": "typing"}, timeout=10)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
r = send_message(m["chat_id"], text, reply_to=None)
|
r = send_message(m["chat_id"], text, reply_to=None, timeout=timeout)
|
||||||
es.append_event("inbox", msg_id, "ReplySent", {
|
es.append_event("inbox", msg_id, "ReplySent", {
|
||||||
"to_chat": m["chat_id"], "text": text[:2000], "tg_ok": bool(r.get("ok")),
|
"to_chat": m["chat_id"], "text": text[:2000], "tg_ok": bool(r.get("ok")),
|
||||||
})
|
})
|
||||||
|
|
@ -131,6 +145,14 @@ def poll_loop():
|
||||||
if not TG_TOKEN:
|
if not TG_TOKEN:
|
||||||
raise SystemExit("SIMBIOT_TG_TOKEN не задан в .env")
|
raise SystemExit("SIMBIOT_TG_TOKEN не задан в .env")
|
||||||
es.init_db()
|
es.init_db()
|
||||||
|
# (t-260929210755) список команд бота — виден в чате как меню "/":
|
||||||
|
# регистрируем при старте поллера (идемпотентно) и после рестартов.
|
||||||
|
try:
|
||||||
|
from . import tg_commands
|
||||||
|
mode = tg_commands.register_bot_commands()
|
||||||
|
print(f"[tg] команды бота зарегистрированы ({mode})", flush=True)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[tg] регистрация команд не удалась: {e}", flush=True)
|
||||||
offset = 0
|
offset = 0
|
||||||
print("[tg] инбокс-поллер запущен", flush=True)
|
print("[tg] инбокс-поллер запущен", flush=True)
|
||||||
while True:
|
while True:
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue