feat(portal-chat): прямой канал портал↔агент без Matrix (t-261001123122-5ea554)
- portal_channel.py: HTTP-клиент портала — pull входящих чата портала (GET /api/agent/<имя>/channel/poll?since=ms), push ответов (POST outbox, ack входящих по msg_id), сек-рефлекс (≤20с) + cron main(). - tg_inbox: чаты 'portal:*' доставляются push'ем на портал (ReplySent), reply_to = портальный update_id (portal: ack consumed входящего). - scheduler: ReflexObserved хук на portal_channel.sec_reflex — pull работает и без LLM-тика, и на машинах без TG. - es: проекционный бранчPortalDelivered выпилен (маркер — ReplySent от tg_inbox); MessageIgnored branch остался нетронутым. - Диагноз: SSH портал→агенты закрыт (timeout :22/:8765 к #83) — канал на исходящем HTTP, AGENT_TOKEN+PORTAL_URL уже пекутся в /etc/coop-reporter.env, agent-scope env добавлены в cloud-init .env (SIMBIOT_AGENT_TOKEN/NAME/CHAT=1).
This commit is contained in:
parent
fe1c569888
commit
0523135bf1
4 changed files with 325 additions and 0 deletions
|
|
@ -456,6 +456,8 @@ def _apply_event(model: dict, ev: dict):
|
|||
"ts": ev["timestamp"],
|
||||
}
|
||||
elif etype == "MessageIgnored":
|
||||
# канал портала (t-261001123121): доставка реплая помечена ReplySent'ом
|
||||
# (portal ReplySent — признак push'а) — отдельный маркер не нужен
|
||||
m = model.setdefault("inbox", {}).get(aid)
|
||||
if m:
|
||||
m["ignored"] = True
|
||||
|
|
|
|||
271
harness/portal_channel.py
Normal file
271
harness/portal_channel.py
Normal file
|
|
@ -0,0 +1,271 @@
|
|||
"""Портальный чат-канал (t-261001123121): HTTP-клиент портала для агента.
|
||||
|
||||
Мандат: «чат напрямую через API харнеса, без Matrix». У харнеса нет
|
||||
принимающего веб-порта (scheduler — цикл, dashboard — read-only), а машина
|
||||
агента общается с порталом ТОЛЬКО исходящим HTTP (SSH вход из портала закрыт
|
||||
— проверено 01.10, timeout 22/8765 к агентам). Поэтому канал — pull/push:
|
||||
|
||||
ВХОД (портал → агент): GET /api/agent/<AGENT>/channel/poll?since=<ms>
|
||||
X-Agent-Token:<AGENT_TOKEN из .env> → {messages: [{msg_id, text, ts}]}
|
||||
ingеcт в стор (MessageReceived, chat_id='portal:<agent>', id=портальный)
|
||||
→ далее штатный инбокс (батч-секция мин-тика, tg-reply, ReplySent).
|
||||
ВЫХОД (агент → портал): POST /api/agent/<AGENT>/channel/poll
|
||||
{"outbox": [{msg_id, text, reply_to, ts}]} → портал пишет в
|
||||
agent_portal_chat (UNIQUE msg_id) — UI видит мгновенно.
|
||||
ПУШ-ответ из тика: send_reply/send_batch_reply для chat 'portal:*' зовут
|
||||
push() вместо Telegram — реплай попадает в портал сразу же (≤2с),
|
||||
не дожидаясь следующего отложенного POST outbox.
|
||||
|
||||
Токены: SIMBIOT_AGENT_TOKEN — тот же reporter-токен (agt_…), что и у
|
||||
coop-reporter; PORTAL_URL — как в .harness-env. Логин/секреты не храню —
|
||||
клиент без состояния, дедуп на портале (msg_id UNIQUE).
|
||||
"""
|
||||
import os
|
||||
import json
|
||||
import time
|
||||
import datetime
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
REPORTER_ENV = "/etc/coop-reporter.env"
|
||||
STATE_FILE = "/root/simbiot-harness/portal/inbox/.channel-last"
|
||||
PENDING_DIR = "/root/simbiot-harness/portal/inbox/pending" # legacy-путь (пусто)
|
||||
OUTBOX_DIR = "/root/simbiot-harness/portal/inbox/outbox" # legacy fallback
|
||||
|
||||
HTTP_TIMEOUT = 8 # портал отвечает быстро; висеть дольше — вред
|
||||
MIN_REFLEX_GAP = 20 # сек-рефлекс не дёргает канал чаще, чем раз в 20с
|
||||
MAX_TEXT = 8000
|
||||
_last_reflex = {"ts": 0.0}
|
||||
|
||||
|
||||
def _now_ms() -> int:
|
||||
return int(time.time() * 1000)
|
||||
|
||||
|
||||
def _cfg() -> dict:
|
||||
cfg = {"portal_url": "", "agent_token": "", "agent_name": ""}
|
||||
for k, ek in (("portal_url", "SIMBIOT_PORTAL_URL"),
|
||||
("agent_token", "SIMBIOT_AGENT_TOKEN"),
|
||||
("agent_name", "SIMBIOT_AGENT_NAME")):
|
||||
v = os.environ.get(ek)
|
||||
if v:
|
||||
cfg[k] = v.strip().rstrip("/")
|
||||
try:
|
||||
with open(REPORTER_ENV) as f:
|
||||
for line in f:
|
||||
if "=" in line:
|
||||
k, v = line.split("=", 1)
|
||||
if k.strip() == "PORTAL_URL" and not cfg["portal_url"]:
|
||||
cfg["portal_url"] = v.strip().rstrip("/")
|
||||
elif k.strip() == "AGENT_TOKEN" and not cfg["agent_token"]:
|
||||
cfg["agent_token"] = v.strip()
|
||||
except OSError:
|
||||
pass
|
||||
if not cfg["agent_name"]:
|
||||
try:
|
||||
with open("/etc/hostname") as f:
|
||||
cfg["agent_name"] = f.read().strip()[:60] or ""
|
||||
except OSError:
|
||||
pass
|
||||
return cfg
|
||||
|
||||
|
||||
def _es():
|
||||
import sys
|
||||
if "/root/simbiot-harness" not in sys.path:
|
||||
sys.path.insert(0, "/root/simbiot-harness")
|
||||
from harness import es
|
||||
return es
|
||||
|
||||
|
||||
def _agent_chat_id(cfg) -> str:
|
||||
return f"portal:{cfg['agent_name']}"
|
||||
|
||||
|
||||
def store_id(portal_msg_id: str) -> str:
|
||||
"""Ключ входящего в сторе: 'm-' + id (та же схема scheduler для TG)."""
|
||||
return "m-" + str(portal_msg_id or "").strip()[:80]
|
||||
|
||||
|
||||
def _ingest_messages(msgs: list) -> int:
|
||||
"""[{msg_id, text, ts}] → MessageReceived в стор (идемпотентно по msg_id).
|
||||
|
||||
Дедуп по read model: повторно пришедшие (POST-подтверждение не доехало)
|
||||
пропускаются — LLM их не увидит второй раз, чат не дублируется.
|
||||
"""
|
||||
if not msgs:
|
||||
return 0
|
||||
try:
|
||||
es = _es()
|
||||
model = es.run_projections()
|
||||
except Exception:
|
||||
return 0 # стор занят —pull повторится позже (между тиками)
|
||||
existing = {str(m.get("update_id")) for m in (model.get("inbox") or {}).values()
|
||||
if isinstance(m, dict) and m.get("update_id") is not None}
|
||||
ingested = 0
|
||||
cfg = _cfg()
|
||||
for d in msgs:
|
||||
mid = str((d or {}).get("msg_id") or "").strip()[:80]
|
||||
text = ((d or {}).get("text") or "").strip()
|
||||
if not mid or not text or mid in existing:
|
||||
continue
|
||||
data = {
|
||||
"update_id": mid,
|
||||
"chat_id": _agent_chat_id(cfg),
|
||||
"user_id": None,
|
||||
"username": ((d.get("author") or "portal")[:60] + " · portal")[:80],
|
||||
"first_name": str(d.get("author_label") or "")[:80] or None,
|
||||
"text": text[:MAX_TEXT],
|
||||
"kind": "portal",
|
||||
"file_id": None, "file_unique_id": None, "duration": None,
|
||||
"date": (d.get("ts") or _now_ms()) // 1000,
|
||||
"source": "portal",
|
||||
}
|
||||
try:
|
||||
es.append_event("inbox", store_id(mid), "MessageReceived", data)
|
||||
except Exception:
|
||||
continue # стор занят на этом msg — следующий проход
|
||||
ingested += 1
|
||||
return ingested
|
||||
|
||||
|
||||
def pull(last_ms: int = 0) -> int:
|
||||
"""ВХОД: GET /channel/poll?since=<last_ms> → ingest. Возвращает число
|
||||
принятых сообщений (0 = тихо/недоступно — ошибки не наружу)."""
|
||||
cfg = _cfg()
|
||||
if not (cfg["portal_url"] and cfg["agent_token"] and cfg["agent_name"]):
|
||||
return 0 # деплой без репортера: канал выключен, не дёргаем
|
||||
url = (f"{cfg['portal_url']}/api/agent/{urllib.parse.quote(cfg['agent_name'])}"
|
||||
f"/channel/poll?since={int(last_ms or 0)}")
|
||||
req = urllib.request.Request(url, headers={"X-Agent-Token": cfg["agent_token"]})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=HTTP_TIMEOUT) as r:
|
||||
data = json.loads(r.read().decode())
|
||||
except Exception:
|
||||
return 0 # сеть/портал недоступны: тихо (cron повторит)
|
||||
msgs = data.get("messages") or []
|
||||
n = _ingest_messages(msgs)
|
||||
# отметить съеденные: POST'ом их msg_id — портал пометит consumed
|
||||
# (вторая попытка того же цикла недоступности не ворошит пустое)
|
||||
if n > 0:
|
||||
_post_outbox([{"msg_id": m.get("msg_id")} for m in msgs
|
||||
if m.get("msg_id")])
|
||||
return n
|
||||
|
||||
|
||||
def push(chat_id: str, text: str, reply_mids=None) -> bool:
|
||||
"""ВЫХОД (немедленный): реплай уровня на сообщение чата портала.
|
||||
|
||||
Вызывается из tg_inbox.send_reply/send_batch_reply для chat 'portal:*'.
|
||||
True — портал принял (caller пишет ReplySent и закрывает инбокс);
|
||||
False — не доставлено (caller ReplySent НЕ пишет → повтор на след. тик).
|
||||
"""
|
||||
text = (text or "").strip()
|
||||
if not text:
|
||||
return False
|
||||
items = [{"msg_id": f"reply-{int(time.time() * 1000)}-{(m or '?')}",
|
||||
"text": text[:MAX_TEXT], "reply_to": m, "ts": _now_ms()}
|
||||
for m in (reply_mids or [None])][:20]
|
||||
return bool(_post_outbox(items, timeout=15))
|
||||
|
||||
|
||||
def _post_outbox(items: list, timeout: int = HTTP_TIMEOUT) -> bool:
|
||||
"""POST /channel/poll {"outbox": items} — портал пишет в agent_portal_chat."""
|
||||
if not items:
|
||||
return True
|
||||
cfg = _cfg()
|
||||
if not (cfg["portal_url"] and cfg["agent_token"] and cfg["agent_name"]):
|
||||
return False
|
||||
url = (f"{cfg['portal_url']}/api/agent/{urllib.parse.quote(cfg['agent_name'])}"
|
||||
f"/channel/poll")
|
||||
req = urllib.request.Request(
|
||||
url, data=json.dumps({"outbox": items[:100]}).encode(),
|
||||
headers={"Content-Type": "application/json",
|
||||
"X-Agent-Token": cfg["agent_token"]})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as r:
|
||||
return bool(json.loads(r.read().decode()).get("ok"))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
# ══════════════ фоновые вызовы (без LLM) ══════════════
|
||||
|
||||
def sec_reflex() -> dict:
|
||||
"""Сек-рефлекс: pull портала + flush outbox-файлов. Дешёво и НЕ чаще
|
||||
MIN_REFLEX_GAP (портал может быть недоступен — рефлекс не должен висеть)."""
|
||||
now = time.time()
|
||||
if now - _last_reflex["ts"] < MIN_REFLEX_GAP:
|
||||
return {"skipped": "rate-limit"}
|
||||
if os.environ.get("SIMBIOT_PORTAL_CHAT", "") == "0":
|
||||
return {"skipped": "disabled"} # мастер-харнес: канал выключен флагом
|
||||
_last_reflex["ts"] = now
|
||||
out = {}
|
||||
out["ingested"] = pull()
|
||||
out["outbox_files"] = _flush_outbox_files()
|
||||
# legacy: file-pending (если портал когда-то писал файлами)
|
||||
try:
|
||||
import glob
|
||||
pfiles = glob.glob(os.path.join(PENDING_DIR, "*.json"))
|
||||
if pfiles:
|
||||
msgs = []
|
||||
for f in pfiles:
|
||||
try:
|
||||
with open(f) as fh:
|
||||
msgs.append(json.load(fh))
|
||||
os.remove(f)
|
||||
except Exception:
|
||||
pass
|
||||
out["ingested_files"] = _ingest_messages(msgs)
|
||||
except Exception:
|
||||
pass
|
||||
return out
|
||||
|
||||
|
||||
def _flush_outbox_files() -> int:
|
||||
"""Fallback-путь через outbox-файлы: push не прошёл → файл; портал
|
||||
принимает их теперь тем же POST (файл = item, id тот же)."""
|
||||
import glob
|
||||
if not os.path.isdir(OUTBOX_DIR):
|
||||
return 0
|
||||
files = sorted(glob.glob(os.path.join(OUTBOX_DIR, "*.json")))
|
||||
if not files:
|
||||
return 0
|
||||
items = []
|
||||
flushed = 0
|
||||
for f in files:
|
||||
try:
|
||||
with open(f) as fh:
|
||||
d = json.load(fh)
|
||||
items.append({"msg_id": d.get("id"), "text": d.get("text"),
|
||||
"reply_to": d.get("reply_to"), "ts": d.get("ts")})
|
||||
except Exception:
|
||||
try:
|
||||
os.remove(f) # битый файл не должен гнить навсегда
|
||||
except OSError:
|
||||
pass
|
||||
continue
|
||||
if not items:
|
||||
return 0
|
||||
if _post_outbox(items, timeout=10):
|
||||
for d in items:
|
||||
mid = d.get("msg_id")
|
||||
try:
|
||||
os.remove(os.path.join(OUTBOX_DIR,
|
||||
f"{str(mid).replace(':', '_')}.json"))
|
||||
except OSError:
|
||||
pass
|
||||
flushed = len(items)
|
||||
return flushed
|
||||
|
||||
|
||||
def main():
|
||||
"""cron-тело (каждую минуту): pull портала + flush outbox."""
|
||||
n = pull()
|
||||
f = _flush_outbox_files()
|
||||
if n or f:
|
||||
print(f"[portal-channel] pulled={n} flushed={f}", flush=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -617,6 +617,19 @@ def _reflex_tick(level_cfg, seq: int, model: dict):
|
|||
except Exception as e:
|
||||
# команды — часть рефлекса, но не должны валить телеметрию
|
||||
print(f"[tg-cmd] ответ на команды не удался: {e}", flush=True)
|
||||
# файловый/HTTP канал чата портала (t-261001123121, без Matrix): pull
|
||||
# входящих чата портала + немедленный push уровня-репляев. Канал
|
||||
# независим от TG: работает и на машинах без TG-токена.
|
||||
try:
|
||||
from . import portal_channel as _pch
|
||||
res = _pch.sec_reflex()
|
||||
if not res.get("skipped") and (res.get("ingested") or res.get("outbox_files")):
|
||||
payload["portal_channel"] = res
|
||||
_log(f"portal-channel: {res}")
|
||||
except SystemExit:
|
||||
raise
|
||||
except Exception as e:
|
||||
print(f"[portal-channel] рефлекс не смог: {e}", flush=True)
|
||||
es.append_event("cycle", f"reflex:{seq}", "ReflexObserved", payload)
|
||||
return payload, None
|
||||
|
||||
|
|
|
|||
|
|
@ -129,6 +129,21 @@ def send_reply(msg_id: str, text: str, timeout: int = 35):
|
|||
raise ValueError(f"входящее {msg_id} не найдено")
|
||||
if m.get("replied"):
|
||||
raise ValueError(f"на {msg_id} уже отвечено")
|
||||
# ── файловый портал-канал (t-261001123121): чаты 'portal:*' отвечаются
|
||||
# HTTP-push'ем на портал — БЕЗ Telegram. У агента может не быть TG-бота
|
||||
# (simbiot-деплой делает TG опциональным), а портал — не телеграм-юзер:
|
||||
# разговоры не смешиваются (по chat_id), доставка — отдельный транспорт.
|
||||
if str(m.get("chat_id") or "").startswith("portal"):
|
||||
from . import portal_channel
|
||||
ok = portal_channel.push(m["chat_id"], text, [m.get("update_id") or msg_id])
|
||||
if not ok:
|
||||
# доставка не состоялась: ReplySent НЕ пишем — сообщение остаётся
|
||||
# без ответа, следующий рефлекс/тик повторит (гейт replied).
|
||||
raise RuntimeError("портал недоступен (push не доставил) — ответят на следующем тике")
|
||||
es.append_event("inbox", msg_id, "ReplySent", {
|
||||
"to_chat": m["chat_id"], "text": text[:2000], "channel": "portal",
|
||||
})
|
||||
return msg_id
|
||||
_load_env() # sendChatAction идёт до send_message: в свежем процессе
|
||||
# (демон/сек-рефлекс) токен ещё не загружен — иначе «печатает…» молча теряется
|
||||
try: # индикатор — best effort: недоступность TG-метода не мешает ответу
|
||||
|
|
@ -138,6 +153,7 @@ def send_reply(msg_id: str, text: str, timeout: int = 35):
|
|||
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")),
|
||||
"channel": "tg",
|
||||
})
|
||||
return msg_id
|
||||
|
||||
|
|
@ -179,6 +195,29 @@ def send_batch_reply(msg_ids: list, text: str, timeout: int = 35):
|
|||
by_chat = {}
|
||||
for mid, m in pending:
|
||||
by_chat.setdefault(m["chat_id"], []).append((mid, m))
|
||||
# ── файловый портал-канал (t-261001123121): батч целиком из чата 'portal:*'
|
||||
# доставляется HTTP-push'ем на портал, без Telegram. Смешивать чаты
|
||||
# нельзя — один сводный текст на один транспорт.
|
||||
portal_chats = [c for c in by_chat if str(c).startswith("portal")]
|
||||
if portal_chats:
|
||||
if len(by_chat) != 1:
|
||||
raise ValueError("батч смешивает портал и Telegram — доставка неоднозначна")
|
||||
from . import portal_channel
|
||||
chat_id = portal_chats[0]
|
||||
ids = [by_chat[chat_id][0][1].get("update_id") or mid
|
||||
for mid, _ in by_chat[chat_id]]
|
||||
ok = portal_channel.push(chat_id, text, ids)
|
||||
if not ok:
|
||||
raise RuntimeError("портал недоступен (push не доставил) — попробуем на следующем тике")
|
||||
for mid, m in by_chat[chat_id]:
|
||||
es.append_event("inbox", mid, "ReplySent", {
|
||||
"to_chat": chat_id,
|
||||
"text": (text if mid == head_mid
|
||||
else f"(сводный ответ батча {batch_id} — см. {head_mid})")[:2000],
|
||||
"channel": "portal",
|
||||
"batch": {"id": batch_id, "size": len(pending), "head": head_mid},
|
||||
})
|
||||
return head_mid
|
||||
for chat_id, items in by_chat.items():
|
||||
try: # индикатор — best effort, как в send_reply
|
||||
_api("sendChatAction", {"chat_id": chat_id, "action": "typing"}, timeout=10)
|
||||
|
|
|
|||
Loading…
Reference in a new issue