Files
ROMFASTSQL/proxmox/lxc171-claude-agent/discord-bridge/bot.py
Claude Agent a89cf201ab fix(discord-bridge): zgomot in bot.log si doua tipuri de stream necunoscute
Trei lucruri observate in logurile de productie dupa restartul precedent.

1. Ecoul propriilor mesaje umplea bot.log cu WARNING. Propriile mesaje au si
   ele `author.bot == True`, iar verificarea generica de bot venea INAINTEA
   celei pe `self_id` — deci raspunsurile botului se jurnalizau ca "bot
   strain", la fiecare mesaj. Verificarea pe `self_id` trece prima (motivul e
   acum precis), iar refuzurile de rutina — propriile mesaje si ceilalti boti —
   merg la DEBUG. Guild / canal / utilizator strain si webhook raman WARNING:
   alea chiar sunt semnal de securitate si erau inecate in zgomot.

2. `tool_progress` (heartbeat la 30s cat timp o unealta ruleaza) devine
   eveniment `ToolProgress`. Mesajul live arata acum "⏳ ruleaza de 2m30s" sub
   unealta curenta — singurul semn ca un tur lung lucreaza si nu a inghetat.

3. `rate_limit_event` devine eveniment `RateLimit` si apare in `/status` la
   randul `utilizare`. Cum nu exista plafon de cost (abonament, nu API),
   fereastra de utilizare e singura limita reala; se avertizeaza in log o data
   per schimbare de stare, nu la fiecare eveniment.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B29CApsP1JkSdjYaGaHpE7
2026-08-31 07:46:51 +00:00

943 lines
38 KiB
Python

"""Adaptorul Discord al puntii: allowlist, comenzi, steering, aprobari.
Impartirea e intentionata:
* `Bridge` -- toata logica, fara discord.py. Primeste obiecte "mesaj" duck-typed,
deci se poate testa integral fara retea si fara Discord.
* `BridgeClient` -- invelisul subtire peste `discord.Client`, doar dispecerat.
T1 (allowlist) si T11 (model + cost) traiesc aici. Nucleul (config/stream/session_store/
runner/limits/render) e consumat ca atare, nu duplicat.
"""
from __future__ import annotations
import asyncio
import contextlib
import io
import logging
import os
import pathlib
import time
from dataclasses import dataclass, field
import commands_slash
import config
import limits as limits_mod
import render
import runner as runner_mod
import session_store
import stream as stream_mod
log = logging.getLogger("discord-bridge.bot")
# discord.py e necesar doar pentru procesul real; logica se testeaza fara el.
try: # pragma: no cover - depinde de mediu
import discord # type: ignore
except ImportError: # pragma: no cover
discord = None # type: ignore
# Lane C (ops) si Lane B (securitate) pot lipsi: importuri tolerante, fara variante proprii.
try: # pragma: no cover
import alerts # type: ignore
except ImportError: # pragma: no cover
class _NoAlerts:
@staticmethod
def alert(level: str, subject: str, body: str, dedup_key: str | None = None) -> None:
log.warning("alerta (%s) %s: %s", level, subject, body)
alerts = _NoAlerts() # type: ignore
try: # pragma: no cover
import cleanup # type: ignore
except ImportError: # pragma: no cover
cleanup = None # type: ignore
try: # pragma: no cover
from security import approvals # type: ignore
except Exception: # pragma: no cover - lipsa modulului NU are voie sa opreasca botul
approvals = None # type: ignore
MODELS = ("sonnet", "opus")
LEGACY_PREFIX = "!" # nu mai executa nimic: doar indiciul de migrare catre `/`
LIVE_TAIL = 1500 # cate caractere aratam in mesajul care se editeaza in timp real
# ------------------------------------------------------------------ allowlist
def _ids(*keys: str) -> set[str]:
"""Reuniunea mai multor chei de allowlist (acceptam si singular, si plural)."""
out: set[str] = set()
for key in keys:
for item in config.get_list(key):
item = item.strip()
if item:
out.add(item)
return out
def guild_ids() -> set[str]:
return _ids("DISCORD_GUILD_IDS", "DISCORD_GUILD_ID")
def channel_ids() -> set[str]:
return _ids("DISCORD_CHANNEL_IDS", "DISCORD_CHANNEL_ID")
def user_ids() -> set[str]:
return _ids("DISCORD_USER_IDS", "DISCORD_USER_ID")
def default_model() -> str:
"""`CLAUDE_MODEL` din env (numele din ops/env.example), altfel `MODEL_DEFAULT`."""
model = (config.get("CLAUDE_MODEL", "") or "").strip()
if model in MODELS:
return model
model = (config.get("MODEL_DEFAULT", "sonnet") or "sonnet").strip()
return model if model in MODELS else "sonnet"
@dataclass(frozen=True)
class Decision:
ok: bool
reason: str = ""
def __bool__(self) -> bool: # pragma: no cover - comoditate
return self.ok
ACCEPTED = Decision(True)
# Refuzuri de rutina: botul isi vede propriile mesaje si pe ale altor boti la
# fiecare raspuns. Nu sunt semnal de securitate, deci merg la DEBUG — altfel
# ineaca in bot.log refuzurile care chiar conteaza (guild/canal/utilizator strain).
ROUTINE_REJECTS = ("propriul mesaj", "bot")
def _attr(obj, name, default=None):
return getattr(obj, name, default)
def channel_candidates(message) -> list[str]:
"""Id-urile de canal relevante: firul insusi si canalul-parinte al firului."""
ch = _attr(message, "channel")
out = []
for value in (_attr(ch, "id"), _attr(ch, "parent_id"), _attr(message, "channel_id")):
if value is not None:
out.append(str(value))
return out
def thread_key(message) -> str:
"""Un fir Discord = o sesiune Claude; canalul principal = sesiunea implicita."""
ch = _attr(message, "channel")
return str(_attr(ch, "id", _attr(message, "channel_id", "0")))
def check_message(message, self_id: str | None = None) -> Decision:
"""T1: filtrul de la intrare. Fail-closed: allowlist lipsa sau goala => refuz.
Refuzul e TACUT in `Bridge.handle_message` (nu confirmam unui strain ca botul
exista); aici doar spunem de ce.
"""
# 1. webhook-uri: un webhook scurs nu are voie sa comande nimic
if _attr(message, "webhook_id") is not None:
return Decision(False, "webhook")
author = _attr(message, "author")
if author is None:
return Decision(False, "fara autor")
# Verificarea `self_id` sta INAINTEA celei generice de bot: propriile mesaje au
# si ele `author.bot == True`, iar altfel ar fi jurnalizate ca "bot strain".
uid = str(_attr(author, "id", ""))
if self_id is not None and uid == str(self_id):
return Decision(False, "propriul mesaj")
if bool(_attr(author, "bot", False)):
return Decision(False, "bot")
guild = _attr(message, "guild")
gid = _attr(guild, "id") if guild is not None else _attr(message, "guild_id")
return check_ids(gid, message, uid)
def check_ids(gid, source, uid: str) -> Decision:
"""Miezul allowlist-ului: acelasi cod pentru mesaje si pentru interactiuni slash.
`source` e orice obiect care expune `channel` / `channel_id` (mesaj sau interactiune).
"""
guilds, channels, users = guild_ids(), channel_ids(), user_ids()
if not guilds or not channels or not users:
return Decision(False, "allowlist goala (fail-closed)")
if gid is None:
return Decision(False, "mesaj privat (fara guild)")
if str(gid) not in guilds:
return Decision(False, f"guild neautorizat {gid}")
if not (set(channel_candidates(source)) & channels):
return Decision(False, f"canal neautorizat {thread_key(source)}")
if str(uid) not in users:
return Decision(False, f"utilizator neautorizat {uid}")
return ACCEPTED
def check_interaction(interaction, self_id: str | None = None) -> Decision:
"""T1 pentru comenzile slash. Aceleasi reguli, acelasi fail-closed ca la mesaje.
O interactiune nu poate veni de la un webhook, deci verificarea aceea lipseste;
restul (bot, propriul bot, guild, canal, utilizator) e identic.
"""
user = _attr(interaction, "user")
if user is None:
return Decision(False, "fara utilizator")
if bool(_attr(user, "bot", False)):
return Decision(False, "bot")
uid = str(_attr(user, "id", ""))
if self_id is not None and uid == str(self_id):
return Decision(False, "propriul mesaj")
guild = _attr(interaction, "guild")
gid = _attr(interaction, "guild_id") or (_attr(guild, "id") if guild is not None else None)
return check_ids(gid, interaction, uid)
# -------------------------------------------------------------------- comenzi
@dataclass(frozen=True)
class Command:
name: str
args: list[str] = field(default_factory=list)
@property
def rest(self) -> str:
return " ".join(self.args)
COMMANDS = ("new", "cd", "model", "status", "stop", "cleanup", "permisiuni", "help")
# Optiunile slash traduse in argumentele pe care cmd_* le stiu deja.
SLASH_FLAGS = {
"new": ("fork", "--fork"),
"cleanup": ("force", "--force"),
"permisiuni": ("revoca", "revoca"),
}
def slash_args(name: str, opts: dict) -> list[str]:
"""`/new fork:True` -> `["--fork"]`, `/cd cale:/x` -> `["/x"]`."""
flag = SLASH_FLAGS.get(name)
if flag is not None:
return [flag[1]] if opts.get(flag[0]) else []
for key in ("cale", "model"):
val = opts.get(key)
if val not in (None, ""):
return [str(val).strip()]
return []
def legacy_prefix_hint(content: str) -> str | None:
"""Vechiul `!cmd` nu mai executa nimic; spunem doar ca a devenit `/cmd`.
Fara asta, `!status` ar pleca la Claude ca prompt si ar costa un tur degeaba.
"""
text = (content or "").strip()
if not text.startswith(LEGACY_PREFIX):
return None
body = text[len(LEGACY_PREFIX):].strip()
if not body:
return None
name = body.split()[0].lower()
if name not in COMMANDS:
return None
return f"Comenzile sunt acum slash: foloseste `/{name}` in loc de `!{name}`."
class FollowupChannel:
"""Canal duck-typed peste `interaction.followup`.
Dupa `defer()` raspunsul se trimite doar prin followup. Invelisul asta lasa
`cmd_*` si `Bridge.say` neschimbate — ele vad tot un obiect cu `.send()`.
"""
def __init__(self, interaction):
self.interaction = interaction
self.id = _attr(interaction, "channel_id")
async def send(self, content=None, **kw):
return await self.interaction.followup.send(content, **kw)
class SlashMessage:
"""Ce vad `cmd_*` in locul unui mesaj: canal + autor. Nimic altceva nu folosesc."""
def __init__(self, interaction, channel):
self.interaction = interaction
self.channel = channel
self.author = _attr(interaction, "user")
self.id = _attr(interaction, "id", "")
# ------------------------------------------------------------------ utilitare
def rss_mb(pid: int | None) -> float:
"""RSS-ul unui proces, in MB. 0.0 daca nu se poate citi."""
if not pid:
return 0.0
try:
with open(f"/proc/{int(pid)}/status", "r", encoding="utf-8") as fh:
for line in fh:
if line.startswith("VmRSS:"):
return round(int(line.split()[1]) / 1024.0, 1)
except (OSError, ValueError, IndexError):
pass
return 0.0
def _reset_text(resets_at: int) -> str:
"""Momentul resetarii ferestrei, in ora locala. 0 / valoare aiurea -> „?"."""
try:
if not resets_at:
return "?"
return time.strftime("%H:%M", time.localtime(int(resets_at)))
except (TypeError, ValueError, OSError):
return "?"
def _tool_line(ev) -> str:
if isinstance(ev, stream_mod.ToolUse):
inp = ev.input or {}
detail = inp.get("command") or inp.get("file_path") or inp.get("pattern") or ""
detail = str(detail).replace("\n", " ")[:80]
return f"⚙️ `{ev.name}` {detail}".rstrip()
return ""
def _progress_line(ev) -> str:
"""`tool_progress` -> „ruleaza de 2m30s". Doar asta arata ca turul e viu."""
secs = max(0, int(ev.elapsed_s or 0))
human = f"{secs}s" if secs < 60 else f"{secs // 60}m{secs % 60:02d}s"
return f"⏳ ruleaza de {human}"
# --------------------------------------------------------------------- Bridge
class Bridge:
"""Logica adaptorului, fara nicio dependinta de discord.py."""
def __init__(
self,
store=None,
runner=None,
limits=None,
*,
get_channel=None,
self_id: str | None = None,
):
self.store = store if store is not None else session_store.SessionStore()
if not getattr(self.store, "loaded", False):
self.store.load()
self.limits = limits if limits is not None else limits_mod.Limits(self.store)
self.runner = runner if runner is not None else runner_mod.RunnerManager(
self.store,
settings=config.SETTINGS_FILE if os.path.exists(config.SETTINGS_FILE) else None,
is_inflight=self.store.is_inflight,
)
self.render = render.RenderManager(self._edit)
self.get_channel = get_channel
self.self_id = self_id
self.rejected: list[str] = [] # diagnostic (si pentru teste)
self.last_result = None # ultimul `result` primit (diagnostic)
self.steered = 0
self.approvals_wired = False
# Ultimul `rate_limit_event` din stream. Cum nu exista plafon de cost
# (abonament, nu API), fereastra de utilizare e singura limita reala.
self.rate_limit = None
self._rate_limit_warned: str | None = None
# ------------------------------------------------------------ iesire
async def _edit(self, target, text: str) -> None:
await target.edit(content=text)
async def say(self, channel, text: str, filename: str = "raspuns.md"):
"""Trimite text respectand limita de 2000 de caractere a Discord."""
out = render.split_message(text, filename=filename)
if isinstance(out, render.Attachment):
if discord is not None:
fh = io.BytesIO(out.content.encode("utf-8"))
return await channel.send(
out.preview, file=discord.File(fh, filename=out.filename)
)
return await channel.send(out.preview)
last = None
for part in out.parts:
last = await channel.send(part)
return last
async def react(self, message, emoji: str) -> None:
with contextlib.suppress(Exception):
await message.add_reaction(emoji)
def channel_of(self, thread_id) -> object | None:
if self.get_channel is None:
return None
try:
return self.get_channel(int(thread_id))
except (TypeError, ValueError):
return None
except Exception: # pragma: no cover - clientul poate arunca
return None
# ------------------------------------------------------------ pornire
async def startup(self) -> list[dict]:
"""T5: anunta tururile pierdute. FARA reluare automata (dubla executie)."""
config.ensure_dirs()
lost = self.store.sweep_lost_turns()
for item in lost:
channel = self.channel_of(item.get("thread_id"))
if channel is None:
log.warning("tur pierdut in firul %s, canal negasit", item.get("thread_id"))
continue
with contextlib.suppress(Exception):
await self.say(channel, f"⚠️ {item.get('warning')}")
self.runner.start_reaper()
self.wire_approvals()
return lost
async def shutdown(self) -> None:
with contextlib.suppress(Exception):
await self.render.stop_all()
with contextlib.suppress(Exception):
await self.runner.stop_all()
# ------------------------------------------------------------ aprobari
def wire_approvals(self) -> bool:
"""Lane B poate lipsi: atunci botul porneste normal, doar fara aprobari."""
if approvals is None:
log.warning("security.approvals lipseste: pornesc fara flux de aprobari")
return False
with contextlib.suppress(Exception):
approvals.set_on_request(self.on_approval_request)
self.approvals_wired = True
return self.approvals_wired
async def on_approval_request(self, request: dict) -> None:
channel = self.channel_of(request.get("thread_id"))
if channel is None:
log.warning("cerere de aprobare fara canal: %s", request.get("request_id"))
return
text = (
f"🔐 **Confirmare ceruta** — `{request.get('tool_name') or '?'}`\n"
f"```\n{str(request.get('command') or '')[:900]}\n```"
)
view = self.approval_view(str(request.get("request_id") or ""))
with contextlib.suppress(Exception):
if view is not None:
await channel.send(text, view=view)
else:
await channel.send(text)
def approval_view(self, request_id: str):
"""View cu Allow / Allow-pe-fir / Deny. Fara discord.py returneaza None."""
if discord is None or approvals is None:
return None
view = discord.ui.View(timeout=None)
def _mk(label: str, decision: str, style):
button = discord.ui.Button(label=label, style=style)
async def _cb(interaction): # pragma: no cover - are nevoie de Discord real
msg = self.decide(str(interaction.user.id), request_id, decision)
with contextlib.suppress(Exception):
await interaction.response.edit_message(content=msg, view=None)
button.callback = _cb
return button
view.add_item(_mk("Allow", "allow", discord.ButtonStyle.success))
view.add_item(_mk("Allow (tot firul)", "allow_session", discord.ButtonStyle.primary))
view.add_item(_mk("Deny", "deny", discord.ButtonStyle.danger))
return view
def decide(self, user_id: str, request_id: str, decision: str) -> str:
"""Apasarea butonului. Verifica din nou allowlist-ul: butonul e vizibil tuturor."""
if str(user_id) not in user_ids():
log.warning("decizie refuzata: utilizator neautorizat %s", user_id)
return "⛔ Nu esti in allowlist."
if approvals is None:
return "⛔ Modulul de aprobari nu e disponibil."
ok = False
with contextlib.suppress(Exception):
ok = bool(approvals.submit_decision(request_id, decision))
if not ok:
return "⚠️ Cererea nu mai exista (expirata sau deja decisa)."
if decision == "allow_session":
return (
f"✅ Permis de <@{user_id}> — **operatiuni de acelasi fel nu se mai "
"confirma in acest fir** (`/permisiuni` le listeaza, "
"`/permisiuni revoca` le sterge)."
)
return f"{'✅ Permis' if decision == 'allow' else '🚫 Refuzat'} de <@{user_id}>."
# ------------------------------------------------------------- intrare
async def handle_message(self, message) -> str:
"""Punctul de intrare. Returneaza o eticheta pentru teste/diagnostic."""
decision = check_message(message, self.self_id)
if not decision.ok:
# Refuz TACUT: nu raspundem, nu reactionam. Doar log local.
self.rejected.append(decision.reason)
level = logging.DEBUG if decision.reason in ROUTINE_REJECTS else logging.WARNING
log.log(
level,
"mesaj respins (%s): guild=%s canal=%s user=%s",
decision.reason,
_attr(_attr(message, "guild"), "id"),
thread_key(message),
_attr(_attr(message, "author"), "id"),
)
return "rejected"
content = (_attr(message, "content", "") or "").strip()
if not content:
return "empty"
tid = thread_key(message)
hint = legacy_prefix_hint(content)
if hint is not None:
await self.say(message.channel, hint)
return "legacy-prefix"
# Steering: un mesaj sosit in timpul unui tur NU deschide un tur nou,
# ci intra pe stdin-ul procesului viu. Asta e functionalitatea centrala.
proc = self.runner.procs.get(tid)
if proc is not None and proc.alive and proc.inflight:
try:
await proc.send(content)
except Exception:
log.exception("steering esuat pe firul %s", tid)
else:
self.steered += 1
await self.react(message, "➡️")
return "steered"
return await self.run_turn(message, tid, content)
# ------------------------------------------------------------- comenzi
async def handle_slash(self, interaction, name: str, **opts) -> str:
"""Punctul de intrare al comenzilor slash. Aceleasi cmd_* ca inainte.
Refuzul e EFEMER (`ephemeral=True`), nu tacut ca la mesaje: cine a apucat sa
vada comanda in lista stie deja ca botul exista, iar o interactiune fara raspuns
apare in Discord ca "application did not respond" — confuz degeaba. Nu se
executa nimic si motivul exact ramane doar in log.
"""
decision = check_interaction(interaction, self.self_id)
if not decision.ok:
self.rejected.append(decision.reason)
log.warning(
"interactiune respinsa (%s): guild=%s canal=%s user=%s comanda=%s",
decision.reason,
_attr(interaction, "guild_id"),
_attr(interaction, "channel_id"),
_attr(_attr(interaction, "user"), "id"),
name,
)
with contextlib.suppress(Exception):
await interaction.response.send_message("⛔ Nu esti autorizat.", ephemeral=True)
return "rejected"
handler = getattr(self, f"cmd_{name}", None)
if handler is None: # nu se poate intampla: comenzile sunt declarate static
with contextlib.suppress(Exception):
await interaction.response.send_message(
f"Comanda `/{name}` nu exista.", ephemeral=True
)
return "unknown-command"
# Discord asteapta confirmarea in 3 secunde. Amanam INTOTDEAUNA si raspundem
# prin followup: `/status` citeste din /proc, `/cleanup` scaneaza cgroup-ul,
# `/new` opreste un proces — oricare poate depasi fereastra.
with contextlib.suppress(Exception):
await interaction.response.defer()
tid = thread_key(interaction)
channel = FollowupChannel(interaction)
proxy = SlashMessage(interaction, channel)
try:
await handler(proxy, tid, Command(name, slash_args(name, opts)))
except Exception as exc: # pragma: no cover - plasa de siguranta
log.exception("comanda /%s a esuat", name)
with contextlib.suppress(Exception):
await self.say(channel, f"⛔ `/{name}` a esuat: {exc}")
return "error"
return f"cmd:{name}"
async def cmd_help(self, message, tid: str, cmd: Command) -> None:
await self.say(
message.channel,
"**Comenzi**\n"
"`/new` sesiune noua · `/new fork:True` sesiune noua din contextul curent\n"
"`/cd cale:<cale>` schimba directorul de lucru\n"
"`/model model:<sonnet|opus>` schimba modelul firului\n"
"`/status` starea firului · `/stop` opreste turul curent\n"
"`/cleanup [force:True]` procese lasate in urma\n"
"`/permisiuni [revoca:True]` ce s-a aprobat pentru tot firul\n"
"Orice alt mesaj din canal pleaca la Claude ca prompt.",
)
async def cmd_new(self, message, tid: str, cmd: Command) -> None:
fork = "--fork" in cmd.args
await self.runner.reset(tid, fork=fork)
async with self.store.lock_for(tid):
fields = {"pid": None, "pid_start_time": None, "inflight": None}
if not fork:
fields["sid"] = None
self.store.update_thread(tid, **fields)
# Sesiune noua = permisiuni noi: aprobarile "pe tot firul" nu se mostenesc.
revoked = 0
if approvals is not None:
with contextlib.suppress(Exception):
revoked = int(approvals.clear_grants(tid))
await self.say(
message.channel,
("🧵 Sesiune noua pornita din contextul celei curente (`--fork`)."
if fork else "🧵 Sesiune noua, curata.")
+ (f" Am revocat si {revoked} aprobare(-ri) pe fir." if revoked else ""),
)
async def cmd_cd(self, message, tid: str, cmd: Command) -> None:
if not cmd.args:
await self.say(message.channel, "Foloseste `/cd cale:<cale>`.")
return
# Fara allowlist de proiecte: accesul larg la /workspace e decizie ferma din plan.
path = os.path.abspath(os.path.expanduser(cmd.rest))
if not os.path.isdir(path):
await self.say(message.channel, f"⛔ `{path}` nu e un director.")
return
async with self.store.lock_for(tid):
self.store.update_thread(tid, cwd=path)
await self.runner.set_options(tid, cwd=path)
await self.say(message.channel, f"📁 Director de lucru: `{path}`")
async def cmd_model(self, message, tid: str, cmd: Command) -> None:
rec = self.store.thread(tid)
if not cmd.args:
await self.say(
message.channel,
f"Model curent: `{rec.get('model') or default_model()}`. "
f"Foloseste `/model model:<{'|'.join(MODELS)}>`.",
)
return
model = cmd.args[0].strip().lower()
if model not in MODELS:
await self.say(message.channel, f"⛔ Model necunoscut `{model}`. Alege: {', '.join(MODELS)}.")
return
async with self.store.lock_for(tid):
self.store.update_thread(tid, model=model)
await self.runner.set_options(tid, model=model)
await self.say(message.channel, f"🤖 Model pentru acest fir: `{model}`.")
async def cmd_status(self, message, tid: str, cmd: Command) -> None:
await self.say(message.channel, self.status_text(tid))
def status_text(self, thread_id: str) -> str:
rec = self.store.thread(thread_id)
proc = self.runner.procs.get(str(thread_id))
alive = bool(proc is not None and proc.alive)
pid = proc.pid if proc is not None else rec.get("pid")
model = rec.get("model") or default_model()
cap = self.limits.cost_cap
lines = [
"**Status**",
"```",
f"fir : {thread_id}",
f"sesiune : {(proc.sid if proc is not None else None) or rec.get('sid') or '(noua)'}",
f"director : {rec.get('cwd') or config.get('DEFAULT_CWD', '/workspace/claude-agent')}",
f"model : {model}",
f"proces : {'viu' if alive else 'oprit'}"
+ (f" pid={pid} rss={rss_mb(pid)} MB" if alive and pid else ""),
f"tur in zbor: {'da' if self.store.is_inflight(thread_id) else 'nu'}",
f"in coada : {'da' if self.limits.queued(thread_id) else 'nu'}",
f"procese : {self.runner.live_count()} vii, {self.limits.free_slots} sloturi libere",
f"cost fir : ${float(rec.get('cost_usd_total') or 0.0):.4f}",
f"utilizare : " + (
"in regula" if (self.rate_limit is None or self.rate_limit.ok)
else f"⚠️ {self.rate_limit.status} ({self.rate_limit.limit_type or '?'}), "
f"reset {_reset_text(self.rate_limit.resets_at)}"
),
f"cost azi : ${self.limits.cost_today():.4f}"
+ (f" / ${cap:.2f}" if self.limits.cap_enabled else " (fara plafon)")
+ (" ⛔ PLAFON ATINS" if self.limits.stopped() else ""),
"```",
]
tail = proc.stderr_tail(10) if proc is not None else []
if tail:
body = "\n".join(tail)[-800:]
lines.append("stderr (ultimele linii):\n```\n" + body + "\n```")
return "\n".join(lines)
def note_rate_limit(self, ev) -> None:
"""Retine fereastra de utilizare; avertizeaza o data per schimbare de stare."""
self.rate_limit = ev
if ev.ok:
self._rate_limit_warned = None
return
if self._rate_limit_warned != ev.status:
self._rate_limit_warned = ev.status
log.warning(
"limita de utilizare: status=%s tip=%s reset=%s",
ev.status, ev.limit_type or "?", _reset_text(ev.resets_at),
)
async def cmd_permisiuni(self, message, tid: str, cmd: Command) -> None:
await self.say(message.channel, self.permissions_text(tid, "revoca" in cmd.args))
def permissions_text(self, thread_id: str, revoke: bool = False) -> str:
"""Ce s-a aprobat pe firul asta cu butonul "Allow (tot firul)"."""
if approvals is None:
return "⚠️ Modulul de aprobari nu e disponibil (Lane B)."
if revoke:
n = 0
with contextlib.suppress(Exception):
n = int(approvals.clear_grants(thread_id))
return (
f"🔒 Am revocat {n} aprobare(-ri) pe fir. Urmatoarele operatiuni "
"periculoase cer din nou confirmare."
if n
else "Nu era nimic de revocat pe firul asta."
)
grants = []
with contextlib.suppress(Exception):
grants = approvals.list_grants(thread_id)
if not grants:
return (
"Nicio aprobare memorata pe firul asta: fiecare operatiune "
"periculoasa se confirma individual."
)
lines = ["**Aprobate pentru firul curent**", "```"]
for g in grants:
age = max(0, int((time.time() - float(g.get("granted_at") or 0)) // 60))
lines.append(f"{g.get('rule', '?'):<18} {g.get('reason', '')} (acum {age} min)")
lines += ["```", "`/permisiuni revoca` le sterge pe toate."]
return "\n".join(lines)
async def cmd_stop(self, message, tid: str, cmd: Command) -> None:
proc = self.runner.procs.get(tid)
if proc is None or not proc.alive:
await self.say(message.channel, "Nu ruleaza nimic in acest fir.")
else:
await proc.stop()
await self.say(message.channel, "🛑 Turul a fost oprit.")
async with self.store.lock_for(tid):
self.store.clear_inflight(tid)
async def cmd_cleanup(self, message, tid: str, cmd: Command) -> None:
if cleanup is None:
await self.say(message.channel, "⚠️ Modulul `cleanup` nu e disponibil (Lane C).")
return
force = "--force" in cmd.args
try:
orphans = await asyncio.to_thread(cleanup.find_orphans, self.store.state)
results = await asyncio.to_thread(cleanup.kill_orphans, orphans, not force)
except Exception as exc:
log.exception("cleanup a esuat")
await self.say(message.channel, f"⛔ `/cleanup` a esuat: {exc}")
return
await self.say(message.channel, cleanup.format_report(orphans, results if force else None))
# ---------------------------------------------------------------- tur
async def run_turn(self, message, tid: str, prompt: str) -> str:
user_id = str(_attr(_attr(message, "author"), "id", ""))
channel = message.channel
rec = self.store.thread(tid)
model = rec.get("model") or default_model()
cwd = rec.get("cwd") or config.get("DEFAULT_CWD", "/workspace/claude-agent")
turn_id = runner_mod.new_turn_id()
try:
async with self.limits.turn(tid, user_id) as timeout:
return await self._turn_body(
message, channel, tid, prompt, rec, model, cwd, turn_id, user_id, timeout
)
except limits_mod.CostCapReached as exc:
# T11: plafonul atins => o spunem in fir si ne oprim.
await self.say(channel, f"⛔ {exc} Botul nu mai accepta tururi azi.")
return "cost-cap"
except limits_mod.RateLimited as exc:
await self.say(channel, f"⏳ {exc}")
return "rate-limited"
async def _turn_body(self, message, channel, tid, prompt, rec, model, cwd,
turn_id, user_id, timeout) -> str:
proc = self.runner.get(tid, cwd=cwd, model=model, sid=rec.get("sid"))
proc.cwd, proc.model = cwd, model
if proc.sid is None and rec.get("sid"):
proc.sid = rec["sid"]
async with self.store.lock_for(tid):
self.store.set_inflight(tid, turn_id, user_id, str(_attr(message, "id", "")))
placeholder = await channel.send("⏳ lucrez…")
chunks: list[str] = []
tools: list[str] = []
progress = "" # ultimul heartbeat al uneltei curente, sters cand incepe alta
loop = self.render.loop_for(str(tid))
async def on_event(ev) -> None:
nonlocal progress
if isinstance(ev, stream_mod.SystemInit) and ev.session_id:
proc.sid = ev.session_id
elif isinstance(ev, stream_mod.AssistantText):
chunks.append(ev.text)
elif isinstance(ev, stream_mod.ToolUse):
tools.append(_tool_line(ev))
progress = ""
elif isinstance(ev, stream_mod.ToolProgress):
progress = _progress_line(ev)
elif isinstance(ev, stream_mod.RateLimit):
self.note_rate_limit(ev)
return
elif isinstance(ev, runner_mod.SessionRestarted):
chunks.append(f"_{ev.text}_\n")
else:
return
live = "".join(chunks)[-LIVE_TAIL:]
if tools:
live = (live + "\n" + tools[-1] + (f" · {progress}" if progress else "")).strip()
loop.queue(placeholder, live or "⏳ lucrez…")
started = time.time()
result = None
outcome = None
try:
outcome = await proc.run_turn(prompt, on_event, timeout)
result = self.last_result = outcome.result
except runner_mod.TurnTimeout as exc:
await self._fail(channel, tid, f"⏱️ {exc}", "tur expirat", str(exc))
return "timeout"
except runner_mod.TurnFailed as exc:
await self._fail(channel, tid, f"⛔ Turul a esuat: {exc}", "tur esuat", str(exc))
return "failed"
except Exception as exc: # pragma: no cover - plasa de siguranta
log.exception("tur neasteptat esuat pe firul %s", tid)
await self._fail(channel, tid, f"⛔ Eroare neasteptata: {exc}", "eroare neasteptata", str(exc))
return "error"
finally:
with contextlib.suppress(Exception):
await loop.stop()
self.render.loops.pop(str(tid), None)
async with self.store.lock_for(tid):
self.store.clear_inflight(tid)
if proc.sid:
self.store.update_thread(tid, sid=proc.sid)
# ---- cost si subsol (T11)
cost = float(getattr(result, "total_cost_usd", 0.0) or 0.0)
duration = int(getattr(result, "duration_ms", 0) or (time.time() - started) * 1000)
self.limits.record_cost(cost, tid)
thread_total = float(self.store.thread(tid).get("cost_usd_total") or 0.0)
text = "".join(chunks).strip() or (getattr(result, "text", "") or "").strip()
if getattr(result, "is_error", False):
text = (text + "\n\n⚠️ Claude a raportat o eroare pentru acest tur.").strip()
body = (text or "_(raspuns gol)_") + "\n" + render.footer(model, duration, cost, thread_total)
out = render.split_message(body)
if isinstance(out, render.Attachment):
with contextlib.suppress(Exception):
await placeholder.edit(content=out.preview)
if discord is not None:
fh = io.BytesIO(out.content.encode("utf-8"))
await channel.send(file=discord.File(fh, filename=out.filename))
else:
with contextlib.suppress(Exception):
await placeholder.edit(content=out.parts[0])
for part in out.parts[1:]:
await channel.send(part)
if self.limits.stopped():
await self.say(
channel,
f"⛔ Plafonul de cost pe azi a fost atins "
f"(${self.limits.cost_today():.2f} / ${self.limits.cost_cap:.2f}). Ma opresc.",
)
return "ok"
async def _fail(self, channel, tid: str, text: str, subject: str, body: str) -> None:
with contextlib.suppress(Exception):
alerts.alert("WARN", subject, f"fir {tid}: {body}", f"turn-fail-{tid}")
with contextlib.suppress(Exception):
await self.say(channel, text)
# --------------------------------------------------------------- client real
def make_client(bridge: Bridge | None = None): # pragma: no cover - are nevoie de discord.py
if discord is None:
raise RuntimeError("discord.py nu e instalat (vezi requirements.txt)")
intents = discord.Intents.default()
intents.message_content = True
class BridgeClient(discord.Client):
"""Invelis subtire: tot ce e logica sta in `Bridge`."""
def __init__(self):
super().__init__(intents=intents)
self.bridge = bridge or Bridge()
self.bridge.get_channel = self.get_channel
self.tree = commands_slash.build_tree(self, self.bridge)
self._started = False
async def setup_hook(self):
# Sync PE GUILD: e instantaneu, spre deosebire de cel global (~1h).
# Esecul nu doboara botul — mesajele obisnuite merg mai departe.
await commands_slash.sync_guilds(self.tree, guild_ids())
async def on_ready(self):
self.bridge.self_id = str(self.user.id) if self.user else None
if not self._started:
self._started = True
await self.bridge.startup()
log.info("conectat ca %s", self.user)
async def on_message(self, message):
await self.bridge.handle_message(message)
async def close(self):
await self.bridge.shutdown()
await super().close()
return BridgeClient()
def setup_logging() -> None: # pragma: no cover
config.ensure_dirs()
handlers: list[logging.Handler] = [logging.StreamHandler()]
# Sub systemd, unitul redirecteaza deja stdout/stderr in bot.log
# (`StandardOutput=append:`). Un FileHandler peste asta ar scrie fiecare
# linie de doua ori in acelasi fisier. La rulare manuala nu exista
# redirectare, deci acolo tinem FileHandler-ul.
if not os.environ.get("INVOCATION_ID"):
with contextlib.suppress(OSError):
handlers.append(logging.FileHandler(pathlib.Path(config.LOG_DIR) / "bot.log"))
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
handlers=handlers,
)
def main() -> int: # pragma: no cover
setup_logging()
token = config.get("DISCORD_TOKEN", "")
if not token:
log.error("DISCORD_TOKEN lipseste din %s", config.ENV_FILE)
return 2
if not (guild_ids() and channel_ids() and user_ids()):
log.error("allowlist incompleta in %s: botul nu ar accepta pe nimeni", config.ENV_FILE)
return 2
client = make_client()
client.run(token, log_handler=None)
return 0
if __name__ == "__main__": # pragma: no cover
raise SystemExit(main())