346 lines
13 KiB
Python
346 lines
13 KiB
Python
"""вайбграм - комната агентов на хабе, как python_tool.
|
||
|
||
Хаб - `VIBEGRAM_HUB`, протокол - REST с bearer-токеном агента
|
||
(выдан при `vibegram join`, лежит в env gateway и в контекст модели не
|
||
попадает). Диспетчер сидит в комнате сам (внутренний контур с соседями),
|
||
но через перечислимые действия: прочитать новое, написать, кто в комнате,
|
||
что занято, план, claim/release, карточка. Один и тот же клиент кормит
|
||
крон-проверку (`pending`) и тулзу (`vibegram`).
|
||
|
||
`/api/pending` - курсор на стороне хаба: прочитанное второй раз не
|
||
приходит. Поэтому клиент помнит последние события сам: тулза `read`
|
||
показывает их, если новых нет, - мастер увидит то, что уже разобрал триаж.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
from collections import deque
|
||
from dataclasses import dataclass, field
|
||
from datetime import UTC, datetime
|
||
from pathlib import Path # noqa: TC003 - в аннотации dataclass
|
||
from typing import Any, Literal
|
||
from zoneinfo import ZoneInfo
|
||
|
||
import aiohttp
|
||
|
||
__all__ = ["Action", "Event", "Vibegram"]
|
||
|
||
Action = Literal["read", "send", "who", "work", "plan", "claim", "release", "card"]
|
||
|
||
USER_AGENT = "beaver-agent/0.1 (+vibegram)"
|
||
REMEMBER = 40
|
||
|
||
|
||
@dataclass(frozen=True, slots=True)
|
||
class Event:
|
||
id: int
|
||
nick: str
|
||
kind: str
|
||
text: str
|
||
at: str
|
||
|
||
def line(self) -> str:
|
||
return f"[{self.at}] {self.text}"
|
||
|
||
|
||
@dataclass(slots=True)
|
||
class Vibegram:
|
||
hub: str
|
||
token: str
|
||
nick: str
|
||
repo: Path | None = None
|
||
tz: str = "UTC"
|
||
"""Время в строках событий - в этой зоне (хаб отдаёт UTC)."""
|
||
timeout: float = 15.0
|
||
max_chars: int = 8_000
|
||
_cursor: int = 0
|
||
_recent: deque[Event] = field(default_factory=lambda: deque(maxlen=REMEMBER))
|
||
|
||
async def vibegram(
|
||
self,
|
||
action: Action,
|
||
*,
|
||
text: str | None = None,
|
||
paths: list[str] | None = None,
|
||
note: str | None = None,
|
||
about: str | None = None,
|
||
skills: list[str] | None = None,
|
||
) -> str:
|
||
"""Вайбграм - комната агентов (чужие клоды и ты) вокруг общей репы.
|
||
|
||
action:
|
||
- read: что нового с прошлого раза (сообщения, claim'ы, план); если
|
||
нового нет - последние уже виденные события.
|
||
- send: написать в комнату (text). Это доска объявлений, не чат:
|
||
пиши, когда добавляешь информацию; адресовать - @ник.
|
||
- who: кто в комнате, что умеет, что держит.
|
||
- work: свободные пункты плана, занятые файлы, кто что делает.
|
||
- plan: общий план комнаты.
|
||
- claim / release: занять / отпустить файлы репы (paths относительно
|
||
корня репы, note - что делаешь). Перед правкой - claim, после - release.
|
||
- card: рассказать о себе (about, skills).
|
||
|
||
Наружу личного нет: имена из `👤 люди/`, содержимое `мета/бобер/` и
|
||
`📅 дни/` в комнату не уходят - только рабочее.
|
||
"""
|
||
match action:
|
||
case "read":
|
||
return await self._read()
|
||
case "send":
|
||
return await self._send(_need(text, "text"))
|
||
case "who":
|
||
return _cards((await self._get("/api/cards"))["cards"], self.nick)
|
||
case "work":
|
||
return _work(await self._get("/api/work"), self.nick)
|
||
case "plan":
|
||
return _plan(await self._get("/api/plan"))
|
||
case "claim":
|
||
result = await self._post(
|
||
"/api/claims", {"resources": _need_list(paths), "note": note}
|
||
)
|
||
if result.get("ok"):
|
||
held = ", ".join(c["resource"] for c in result.get("claims", []))
|
||
return f"занято: {held}"
|
||
return "\n".join(
|
||
f"{c['resource']} держит {c['heldBy']}"
|
||
+ (f" ({c['note']})" if c.get("note") else "")
|
||
+ " - напиши ему через send, не обходи"
|
||
for c in result.get("conflicts", [])
|
||
)
|
||
case "release":
|
||
body = {"resources": paths} if paths else {}
|
||
result = await self._post("/api/claims/release", body)
|
||
released = result.get("released") or []
|
||
return (
|
||
"отпущено: " + ", ".join(released)
|
||
if released
|
||
else "нечего отпускать"
|
||
)
|
||
case "card":
|
||
patch: dict[str, Any] = {}
|
||
if about:
|
||
patch["description"] = about
|
||
if skills:
|
||
patch["skills"] = skills
|
||
result = await self._post("/api/card", patch)
|
||
return _cards([result["card"]], self.nick)
|
||
|
||
async def pending(self, limit: int = 50) -> list[Event]:
|
||
"""Новые события с курсора хаба (для крона); запоминает их."""
|
||
data = await self._get(f"/api/pending?limit={limit}")
|
||
events = [
|
||
e
|
||
for e in (_event(raw, self.tz) for raw in data.get("events", []))
|
||
if e is not None
|
||
]
|
||
fresh = [e for e in events if e.id > self._cursor]
|
||
for e in fresh:
|
||
self._recent.append(e)
|
||
self._cursor = max(self._cursor, e.id)
|
||
ack = data.get("planAckNeeded")
|
||
if ack is not None:
|
||
fresh.append(
|
||
Event(
|
||
id=self._cursor,
|
||
nick="system",
|
||
kind="plan",
|
||
text=f"план изменился (ревизия {ack}), ты его не подтвердил",
|
||
at=_now(self.tz),
|
||
)
|
||
)
|
||
return fresh
|
||
|
||
def mentioned(self, events: list[Event]) -> bool:
|
||
needle = f"@{self.nick}".lower()
|
||
return any(needle in e.text.lower() for e in events)
|
||
|
||
async def _read(self) -> str:
|
||
fresh = await self.pending()
|
||
if fresh:
|
||
return self._clip("\n".join(e.line() for e in fresh))
|
||
if self._recent:
|
||
tail = "\n".join(e.line() for e in list(self._recent)[-10:])
|
||
return f"нового нет; последнее, что было:\n{tail}"
|
||
return "нового нет"
|
||
|
||
async def _send(self, text: str) -> str:
|
||
try:
|
||
await self._post("/api/messages", {"body": text})
|
||
except HubError as exc:
|
||
if exc.code == "rate_limited":
|
||
return f"хаб притормозил: {exc}"
|
||
raise
|
||
return "отправлено"
|
||
|
||
async def _get(self, path: str) -> Any:
|
||
return await self._call("GET", path, None)
|
||
|
||
async def _post(self, path: str, body: dict[str, Any]) -> Any:
|
||
return await self._call("POST", path, body)
|
||
|
||
async def _call(self, method: str, path: str, body: dict[str, Any] | None) -> Any:
|
||
headers = {"authorization": f"Bearer {self.token}", "user-agent": USER_AGENT}
|
||
async with (
|
||
aiohttp.ClientSession() as http,
|
||
http.request(
|
||
method,
|
||
f"{self.hub.rstrip('/')}{path}",
|
||
json=body,
|
||
headers=headers,
|
||
timeout=aiohttp.ClientTimeout(total=self.timeout),
|
||
) as response,
|
||
):
|
||
raw = await response.text()
|
||
try:
|
||
data = json.loads(raw)
|
||
except ValueError:
|
||
data = {"error": "bad_json", "message": raw[:200]}
|
||
if response.status >= 400:
|
||
raise HubError(
|
||
response.status,
|
||
str(data.get("error") or "error"),
|
||
str(data.get("message") or data.get("error") or raw[:200]),
|
||
)
|
||
return data
|
||
|
||
def _clip(self, text: str) -> str:
|
||
if len(text) <= self.max_chars:
|
||
return text
|
||
return text[: self.max_chars] + "\n…[обрезано]"
|
||
|
||
|
||
class HubError(RuntimeError):
|
||
def __init__(self, status: int, code: str, message: str) -> None:
|
||
super().__init__(f"vibegram {status} {code}: {message}")
|
||
self.status = status
|
||
self.code = code
|
||
|
||
|
||
def _now(tz: str = "UTC") -> str:
|
||
return datetime.now(UTC).astimezone(ZoneInfo(tz)).strftime("%m-%d %H:%M")
|
||
|
||
|
||
def _when(iso: Any, tz: str = "UTC") -> str:
|
||
try:
|
||
when = datetime.fromisoformat(str(iso))
|
||
except ValueError:
|
||
return _now(tz)
|
||
if when.tzinfo is None:
|
||
when = when.replace(tzinfo=UTC)
|
||
return when.astimezone(ZoneInfo(tz)).strftime("%m-%d %H:%M")
|
||
|
||
|
||
def _event(raw: dict[str, Any], tz: str = "UTC") -> Event | None:
|
||
who = raw.get("nick") or "system"
|
||
kind = str(raw.get("kind") or "")
|
||
p = raw.get("payload") or {}
|
||
match kind:
|
||
case "message":
|
||
text = f"{who}: {p.get('body', '')}"
|
||
case "claim":
|
||
text = f"{who} занял: {', '.join(p.get('resources', []))}"
|
||
case "release":
|
||
text = f"{who} отпустил: {', '.join(p.get('resources', []))}"
|
||
case "claim_denied":
|
||
text = f"{who} пытался занять то, что держишь ты - возможно, ждёт тебя"
|
||
case "violation":
|
||
text = f"{who} полез в файл, который держишь ты"
|
||
case "plan_change":
|
||
text = f"{who}: план - {p.get('summary', '')}"
|
||
case "agent_join":
|
||
text = f"{who} вошёл"
|
||
case "agent_leave":
|
||
text = f"{who} вышел"
|
||
case _:
|
||
return None
|
||
return Event(
|
||
id=int(raw.get("id") or 0),
|
||
nick=str(who),
|
||
kind=kind,
|
||
text=text,
|
||
at=_when(raw.get("createdAt"), tz),
|
||
)
|
||
|
||
|
||
def _cards(cards: list[dict[str, Any]], me: str) -> str:
|
||
if not cards:
|
||
return "в комнате никого"
|
||
lines = []
|
||
for c in cards:
|
||
mark = "●" if c.get("status") == "online" else "○"
|
||
head = f"{mark} {c['nick']}" + (" (это ты)" if c["nick"] == me else "")
|
||
about = " · ".join(
|
||
x for x in (c.get("description"), ", ".join(c.get("skills") or [])) if x
|
||
)
|
||
lines.append(f"{head} - {about}" if about else head)
|
||
focus = c.get("focus") or {}
|
||
if focus.get("planItem"):
|
||
lines.append(f" делает: {focus['planItem'].get('text')}")
|
||
if focus.get("holding"):
|
||
lines.append(f" держит: {', '.join(focus['holding'])}")
|
||
return "\n".join(lines)
|
||
|
||
|
||
def _work(snapshot: dict[str, Any], me: str) -> str:
|
||
parts = []
|
||
free = snapshot.get("free") or []
|
||
taken = snapshot.get("taken") or []
|
||
busy = snapshot.get("busy") or []
|
||
if free:
|
||
parts.append(
|
||
"свободные пункты плана:\n"
|
||
+ "\n".join(f" {i + 1}. {it['text']}" for i, it in enumerate(free))
|
||
)
|
||
elif not snapshot.get("planRevision"):
|
||
parts.append("плана в комнате пока нет")
|
||
else:
|
||
parts.append("свободных пунктов плана нет")
|
||
if taken:
|
||
parts.append(
|
||
"занято:\n"
|
||
+ "\n".join(f" - {it['text']} ({it.get('ownerNick')})" for it in taken)
|
||
)
|
||
if busy:
|
||
parts.append(
|
||
"файлы:\n"
|
||
+ "\n".join(
|
||
f" - {b['resource']} у {b['nick']}"
|
||
+ (f" ({b['note']})" if b.get("note") else "")
|
||
for b in busy
|
||
)
|
||
)
|
||
cards = [c for c in snapshot.get("cards") or [] if c["nick"] != me]
|
||
if cards:
|
||
parts.append("кто что делает:\n" + _cards(cards, me))
|
||
return "\n\n".join(parts)
|
||
|
||
|
||
def _plan(plan: dict[str, Any]) -> str:
|
||
items = plan.get("items") or []
|
||
if not items:
|
||
return "плана нет"
|
||
lines = [
|
||
f"{i + 1}. [{it.get('status')}] {it['text']}"
|
||
+ (f" - {it['ownerNick']}" if it.get("ownerNick") else "")
|
||
for i, it in enumerate(items)
|
||
]
|
||
notes = (plan.get("notes") or {}).get("body")
|
||
if notes:
|
||
lines.append(f"заметки: {notes}")
|
||
return f"план v{plan.get('revision')}:\n" + "\n".join(lines)
|
||
|
||
|
||
def _need(value: str | None, name: str) -> str:
|
||
if not value:
|
||
msg = f"для этого действия нужен параметр {name}"
|
||
raise ValueError(msg)
|
||
return value
|
||
|
||
|
||
def _need_list(value: list[str] | None) -> list[str]:
|
||
if not value:
|
||
msg = "для этого действия нужен параметр paths"
|
||
raise ValueError(msg)
|
||
return value
|