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-тик в сабпроцессе
|
||||
(глобальный asyncio-рантайм Strands не переживает 2+ `create_harness`)
|
||||
- `harness/commands.py` — command side
|
||||
- `harness/tg_commands.py` — без-LLM команды бота (сек-рефлекс)
|
||||
- `cli.py` — init/task/task-await/tasks/status/events/requeue
|
||||
|
||||
## CLI
|
||||
|
|
@ -87,6 +88,24 @@ journalctl -u simbiot-harness -f
|
|||
отвечает `cli.py tg-reply` (Telegram-отправка + `ReplySent` закрывает входящее).
|
||||
Белый список чатов в `.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_not = sub.add_parser("tg-notify", help="отправить сообщение в чат харнеса (уведомление)")
|
||||
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.add_argument("reason")
|
||||
|
|
@ -152,6 +156,19 @@ def main():
|
|||
from harness import tg_inbox
|
||||
r = tg_inbox.notify_chat(args.text)
|
||||
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":
|
||||
es.init_db()
|
||||
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()
|
||||
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"))]
|
||||
if inbox:
|
||||
lines = []
|
||||
|
|
@ -292,7 +298,12 @@ def process_level(level_cfg, model: dict):
|
|||
|
||||
|
||||
def _reflex_tick(level_cfg, seq: int, model: dict):
|
||||
"""Секундный рефлекс: телеметрия + алерты, без LLM."""
|
||||
"""Секундный рефлекс: телеметрия + алерты + без-LLM команды, без LLM.
|
||||
|
||||
Команды бота (/лестница, /статус, /задачи, /помощь) — мгновенные
|
||||
ответы из read model (t-260929210755): мин-уровень на них не
|
||||
тратится, ReplySent закрывает входящее сразу после отправки.
|
||||
"""
|
||||
import os
|
||||
load = None
|
||||
try:
|
||||
|
|
@ -306,6 +317,17 @@ def _reflex_tick(level_cfg, seq: int, model: dict):
|
|||
thr = (model.get("policy") or {}).get("cpu_alert_threshold", 8)
|
||||
if load is not None and load >= 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)
|
||||
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 urllib.request
|
||||
import urllib.parse
|
||||
import urllib.error
|
||||
|
||||
from . import es
|
||||
|
||||
|
|
@ -43,13 +44,25 @@ def _api(method: str, params: dict, timeout: int = 35):
|
|||
return json.loads(r.read().decode())
|
||||
|
||||
|
||||
def send_message(chat_id, text: str, reply_to=None):
|
||||
"""Отправка сообщения (используется tg-reply и алертами)."""
|
||||
def send_message(chat_id, text: str, reply_to=None, timeout: int = 35):
|
||||
"""Отправка сообщения (используется tg-reply и алертами).
|
||||
|
||||
timeout: сек-тик (без-LLM команды) передаёт короткий — рефлекс не
|
||||
должен висеть на TG дольше пары секунд.
|
||||
"""
|
||||
_load_env()
|
||||
params = {"chat_id": chat_id, "text": text, "parse_mode": "HTML"}
|
||||
if 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):
|
||||
|
|
@ -101,12 +114,13 @@ def receive_message(update: dict):
|
|||
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 (закрывает входящее).
|
||||
|
||||
Перед отправкой шлём sendChatAction typing — в TG видно «печатает…».
|
||||
sendChatAction живёт ~5с и НЕ обновляется сам — этого достаточно для
|
||||
минутного пульса (типичный лаг ответа < 5с после начала отправки).
|
||||
timeout — как в send_message (короткий для сек-рефлекса).
|
||||
"""
|
||||
model = es.run_projections()
|
||||
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)
|
||||
except Exception:
|
||||
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", {
|
||||
"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:
|
||||
raise SystemExit("SIMBIOT_TG_TOKEN не задан в .env")
|
||||
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
|
||||
print("[tg] инбокс-поллер запущен", flush=True)
|
||||
while True:
|
||||
|
|
|
|||
Loading…
Reference in a new issue