Files
2026-09-13 10:18:12 +00:00

1412 lines
58 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 base64
import contextlib
import io
import logging
import os
import pathlib
import threading
import time
from dataclasses import dataclass, field
import commands_slash
import config
import infra_actions
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")))
# --------------------------------------------------------------- atasamente
# Claude vede imaginile direct (blocuri `image` in mesajul de utilizator). Fisierele
# text sunt inserate in prompt ca text. Restul sunt doar numite, ca sa nu dispara tacut.
MAX_IMAGE_ATTACHMENTS = 4
# Limita API pentru o imagine e ~5 MB DUPA codarea base64, care umfla cu ~4/3.
# 3,5 MB brut => ~4,7 MB base64, sub prag cu marja.
MAX_IMAGE_BYTES = 3_500_000
MAX_TEXT_ATTACHMENTS = 4
MAX_TEXT_BYTES = 100_000 # per fisier text; peste atat taiem si spunem ca am taiat
# Doar astea sunt acceptate de API ca imagine. `image/jpg`, `image/svg+xml`,
# `image/bmp`, `image/heic` NU sunt — ajung in lista de ignorate, nu in prompt.
SUPPORTED_IMAGE_TYPES = {"image/jpeg", "image/png", "image/gif", "image/webp"}
_EXT_TO_IMAGE_TYPE = {
".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png",
".gif": "image/gif", ".webp": "image/webp",
}
# Extensii tratate ca text simplu. Fara .doc/.pdf/.xlsx — alea nu sunt text.
TEXT_EXTENSIONS = {
".txt", ".md", ".log", ".csv", ".tsv", ".json", ".yaml", ".yml", ".toml", ".ini",
".cfg", ".conf", ".env", ".sql", ".py", ".js", ".ts", ".sh", ".bash", ".zsh",
".html", ".css", ".xml", ".diff", ".patch", ".pck", ".prg", ".vbs", ".ps1",
}
def _extension(filename: str) -> str:
dot = filename.rfind(".")
return filename[dot:].lower() if dot > 0 else ""
def image_media_type(content_type: str, filename: str) -> str | None:
"""Tipul MIME normalizat daca e o imagine pe care API-ul o accepta, altfel None.
Discord trimite uneori `image/png; charset=utf-8` sau nimic; ne bazam pe extensie
ca rezerva. `image/jpg` (nestandard, dar apare) se normalizeaza la `image/jpeg`.
"""
mime = (content_type or "").split(";")[0].strip().lower()
if mime == "image/jpg":
mime = "image/jpeg"
if mime in SUPPORTED_IMAGE_TYPES:
return mime
if not mime or mime == "application/octet-stream":
return _EXT_TO_IMAGE_TYPE.get(_extension(filename))
return None
def is_text_attachment(content_type: str, filename: str) -> bool:
mime = (content_type or "").split(";")[0].strip().lower()
if mime.startswith("text/"):
return True
if mime in ("application/json", "application/xml", "application/x-sh"):
return True
return _extension(filename) in TEXT_EXTENSIONS
async def build_user_content(message, text: str) -> str | list[dict]:
"""Text simplu daca nu sunt atasamente; altfel blocuri (format Anthropic).
Ordinea blocurilor: imaginile intai, textul la final — asa cere ghidul Anthropic
ca modelul sa lege intrebarea de imaginile deja vazute.
"""
attachments = list(_attr(message, "attachments", None) or [])
if not attachments:
return text
blocks: list[dict] = []
extras: list[str] = [] # fisiere text, inserate dupa imagini
skipped: list[str] = [] # ce n-a putut fi citit, ca sa nu dispara tacut
images = texts = 0
for att in attachments:
filename = str(_attr(att, "filename", "atasament"))
content_type = str(_attr(att, "content_type", "") or "")
declared = int(_attr(att, "size", 0) or 0)
media_type = image_media_type(content_type, filename)
if media_type and images >= MAX_IMAGE_ATTACHMENTS:
skipped.append(f"{filename} (peste limita de {MAX_IMAGE_ATTACHMENTS} imagini)")
continue
if media_type and declared > MAX_IMAGE_BYTES:
skipped.append(f"{filename} (imagine prea mare, {declared // 1024} KB)")
continue
if not media_type and is_text_attachment(content_type, filename):
if texts >= MAX_TEXT_ATTACHMENTS:
skipped.append(f"{filename} (peste limita de {MAX_TEXT_ATTACHMENTS} fisiere text)")
continue
elif not media_type:
skipped.append(f"{filename} (tip neacceptat: {content_type or 'necunoscut'})")
continue
try:
data = await att.read()
except Exception:
log.exception("nu am putut descarca atasamentul %s", filename)
skipped.append(f"{filename} (eroare la descarcare)")
continue
if media_type:
# Marimea reala poate diferi de cea declarata; recontrolam pe octetii primiti.
if len(data) > MAX_IMAGE_BYTES:
skipped.append(f"{filename} (imagine prea mare, {len(data) // 1024} KB)")
continue
blocks.append({
"type": "image",
"source": {
"type": "base64",
"media_type": media_type,
"data": base64.b64encode(data).decode("ascii"),
},
})
images += 1
continue
body = data[:MAX_TEXT_BYTES].decode("utf-8", errors="replace")
if len(data) > MAX_TEXT_BYTES:
body += f"\n… (trunchiat, fisierul are {len(data)} octeti)"
extras.append(f"--- fisier atasat: {filename} ---\n{body}")
texts += 1
if not blocks and not extras:
# Doar atasamente ilizibile: nu are rost un mesaj multi-bloc, trimitem text.
note = " (Atasamente ignorate: " + ", ".join(skipped) + ")" if skipped else ""
return (text + note).strip() or "Utilizatorul a trimis doar atasamente ilizibile."
parts = []
if text:
parts.append(text)
elif blocks:
parts.append("Utilizatorul a trimis un atasament fara text. Analizeaza imaginea.")
else:
parts.append("Utilizatorul a trimis un fisier fara text.")
parts.extend(extras)
if skipped:
parts.append("(Atasamente ignorate: " + ", ".join(skipped) + ")")
blocks.append({"type": "text", "text": "\n\n".join(parts)})
return blocks
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", "infra")
# 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"]`."""
if name == "infra":
s = opts.get("simulare")
return [opts.get("actiune", "")] + (["simulare"] if s is True else ["real"] if s is False else [])
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 infra_status_text(st: dict) -> str:
"""Rezumatul `/infra stare`: cvorum, noduri, HA, replicare, UPS, backup, SSL."""
quorum = st.get("quorum", {})
guests = st.get("guests", {})
ha = st.get("ha", [])
repl = st.get("replication", {})
ups = st.get("ups")
backup = st.get("backup")
ssl = st.get("ssl")
lines = [
"**Stare cluster**",
"```",
f"cvorum : {'da' if quorum.get('quorate') else 'NU'} ({quorum.get('votes', 0)}/{quorum.get('expected', 0)})",
f"guest-uri: {guests.get('running', 0)}/{guests.get('total', 0)} pornite",
f"HA : {len(ha)} servicii" + (
f", {sum(1 for h in ha if h.get('state') != 'started')} nu sunt 'started'" if ha else ""
),
f"replicare: {repl.get('ok', 0)}/{repl.get('total', 0)} ok" + (
f", esuate: {', '.join(repl.get('failed', []))}" if repl.get("failed") else ""
),
]
if ups:
lines.append(f"UPS : {ups.get('status', '?')}, incarcare {ups.get('charge', 0):.0f}%")
if backup:
full, cum = backup.get("full_age_h"), backup.get("cum_age_h")
lines.append(
"backup : FULL " + (f"{full:.1f}h" if full is not None else "?")
+ ", CUM " + (f"{cum:.1f}h" if cum is not None else "?")
)
if ssl:
lines.append(f"SSL : {ssl.get('domain')} expira in {ssl.get('min_days')} zile")
for n in st.get("nodes", []):
lines.append(f" {n['name']:<9}: {'online' if n['online'] else 'OFFLINE'}, {n.get('guests', 0)} guest-uri")
lines.append("```")
if st.get("errors"):
lines.append("⚠️ erori: " + ", ".join(f"{h}: {e}" for h, e in st["errors"].items()))
return "\n".join(lines)
def infra_result_text(label: str, result: dict) -> str:
"""Mesajul din `run()`/`stop()` al infra_actions, gata de trimis in fir."""
if not result.get("ok"):
return f"⛔ {label} a esuat (cod {result.get('code')}): {result.get('error', '?')}"
parts = [f"✅ {label} pornit."]
if result.get("log"):
parts.append(f"Jurnal: `{result['log']}`")
if result.get("output"):
parts.append(f"```\n{result['output'][:1500]}\n```")
if result.get("warning"):
parts.append(f"⚠️ {result['warning']}")
return "\n".join(parts)
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
self._infra_boot_task: asyncio.Task | 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()
with contextlib.suppress(Exception):
self._infra_boot_task = asyncio.create_task(self.infra_boot_check())
return lost
async def shutdown(self) -> None:
if self._infra_boot_task is not None:
self._infra_boot_task.cancel()
with contextlib.suppress(Exception, asyncio.CancelledError):
await self._infra_boot_task
self._infra_boot_task = 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()
attachments = _attr(message, "attachments", None) or []
if not content and not attachments:
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"
prompt = await build_user_content(message, content) if attachments else content
# 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(prompt)
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, prompt)
# ------------------------------------------------------------- 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"
"`/infra actiune:<...>` infrastructura: stare, oprire/pornire cluster, UPS, DR\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)
# ---------------------------------------------------------------- infra
async def cmd_infra(self, message, tid: str, cmd: Command) -> None:
if not cmd.args:
await self.say(message.channel, "Foloseste `/infra actiune:<...>`.")
return
action_id = cmd.args[0]
spec = infra_actions.ACTIONS.get(action_id)
if spec is None:
await self.say(message.channel, f"⛔ Actiune necunoscuta: `{action_id}`.")
return
if action_id == "stare":
st = await asyncio.to_thread(infra_actions.status, True)
out = infra_status_text(st)
await self.say(message.channel, out)
return
if action_id == "sarcini":
st = await asyncio.to_thread(infra_actions.status, True)
await self.say(message.channel, await self.infra_jobs_text(st))
return
dry = ("real" not in cmd.args) if spec["dry"] else False
st = await asyncio.to_thread(infra_actions.status, True)
ok, reason = infra_actions.check(action_id, st, dry)
if not ok:
await self.say(message.channel, f"⛔ {spec['label']}: {reason}")
return
if spec["confirm"] and not dry:
text = (
f"⚠️ **{spec['label']}** — confirmi?\n{spec['desc']}\n"
f"Precondiție: {spec['pre']}"
)
if action_id == "oprire":
text += "\nInstructiunile de pornire se fixeaza in canal la finalul opririi."
view = self.infra_view(action_id, dry)
if view is not None:
await message.channel.send(text, view=view)
else:
await self.say(message.channel, text + "\n(fara discord.py, nu pot arata butoane de confirmare)")
return
result = await asyncio.to_thread(infra_actions.run, action_id, dry)
await self.say(message.channel, infra_result_text(spec["label"], result))
async def infra_jobs_text(self, st: dict) -> str:
jobs = [j for j in st.get("jobs", []) if j.get("active")]
if not jobs:
return "Niciun job activ."
parts = []
for j in jobs:
tail = await asyncio.to_thread(infra_actions.log_tail, j["id"], 15)
lines = "\n".join(tail.get("lines", []))
parts.append(f"**{j['id']}** ({j['host']})\n```\n{lines}\n```")
text = "\n".join(parts)
return text if len(text) <= 1800 else text[:1800] + "\n… (trunchiat)"
def infra_view(self, action_id: str, dry: bool):
"""View cu Confirma / Anuleaza pentru o actiune infra. Fara discord.py: None."""
if discord is None:
return None
spec = infra_actions.ACTIONS[action_id]
view = discord.ui.View(timeout=300)
async def _confirm_cb(interaction): # pragma: no cover - are nevoie de Discord real
if str(interaction.user.id) not in user_ids():
with contextlib.suppress(Exception):
await interaction.response.send_message("⛔ Nu esti autorizat.", ephemeral=True)
return
with contextlib.suppress(Exception):
await interaction.response.edit_message(content=f"⏳ {spec['label']}…", view=None)
st = await asyncio.to_thread(infra_actions.status, True)
ok, reason = infra_actions.check(action_id, st, dry)
if not ok:
with contextlib.suppress(Exception):
await interaction.edit_original_response(content=f"⛔ {spec['label']}: {reason}")
return
result = await asyncio.to_thread(infra_actions.run, action_id, dry)
with contextlib.suppress(Exception):
await interaction.edit_original_response(content=infra_result_text(spec["label"], result))
async def _cancel_cb(interaction): # pragma: no cover - are nevoie de Discord real
with contextlib.suppress(Exception):
await interaction.response.edit_message(content=f"Anulat: {spec['label']}.", view=None)
confirm = discord.ui.Button(label="Confirma", style=discord.ButtonStyle.danger)
cancel = discord.ui.Button(label="Anuleaza", style=discord.ButtonStyle.secondary)
confirm.callback = _confirm_cb
cancel.callback = _cancel_cb
view.add_item(confirm)
view.add_item(cancel)
return view
def infra_boot_view(self):
"""View pentru reamintirea de pornire la boot: Porneste / Simulare / Nu acum."""
if discord is None:
return None
view = discord.ui.View(timeout=None)
label = infra_actions.ACTIONS["pornire"]["label"]
async def _run(interaction, dry: bool): # pragma: no cover - are nevoie de Discord real
if str(interaction.user.id) not in user_ids():
with contextlib.suppress(Exception):
await interaction.response.send_message("⛔ Nu esti autorizat.", ephemeral=True)
return
with contextlib.suppress(Exception):
await interaction.response.edit_message(content=f"⏳ {label}…", view=None)
result = await asyncio.to_thread(infra_actions.run, "pornire", dry)
with contextlib.suppress(Exception):
await interaction.edit_original_response(content=infra_result_text(label, result))
async def _skip_cb(interaction): # pragma: no cover - are nevoie de Discord real
with contextlib.suppress(Exception):
await interaction.response.edit_message(view=None)
start = discord.ui.Button(label="Porneste guest-urile", style=discord.ButtonStyle.success)
sim = discord.ui.Button(label="Simulare", style=discord.ButtonStyle.secondary)
skip = discord.ui.Button(label="Nu acum", style=discord.ButtonStyle.secondary)
start.callback = lambda interaction: _run(interaction, False)
sim.callback = lambda interaction: _run(interaction, True)
skip.callback = _skip_cb
view.add_item(start)
view.add_item(sim)
view.add_item(skip)
return view
async def infra_boot_check(self) -> None:
"""La pornire: daca clusterul e deja sus dar inca in mentenanta (repornit
manual, crontab-ul inca poarta #MENTENANTA), aminteste sa se dea /infra
pornire. Nu are voie sa doboare botul: orice eroare ramane doar in log."""
try:
for attempt in range(20):
try:
st = await asyncio.to_thread(infra_actions.status, True)
except Exception:
log.exception("infra_boot_check: status() a esuat")
st = None
if st is not None:
if attempt == 0 and not st.get("maint", 0):
return
if infra_actions.maintenance_pending(st):
await self._infra_post_maint_reminder(st)
return
await asyncio.sleep(30)
except Exception: # pragma: no cover - plasa de siguranta
log.exception("infra_boot_check a esuat")
async def _infra_post_maint_reminder(self, st: dict) -> None:
chans = config.get_list("DISCORD_CHANNEL_IDS") or config.get_list("DISCORD_CHANNEL_ID")
if not chans:
return
channel = self.channel_of(chans[0])
if channel is None:
return
guests = st.get("guests", {})
n = max(0, int(guests.get("total", 0)) - int(guests.get("running", 0)))
text = f"🟢 Cluster sus — 3/3 noduri · {n} guest-uri de pornit."
view = self.infra_boot_view()
with contextlib.suppress(Exception):
if view is not None:
await channel.send(text, view=view)
else:
await channel.send(text)
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 | list[dict]) -> 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)
# ------------------------------------------------------------------ watchdog
# Iesire cu cod nenul => systemd (Restart=on-failure) reporneste procesul.
WATCHDOG_EXIT_CODE = 3
class GatewayWatchdog:
"""Masoara de cat timp e gatewayul cazut.
discord.py memoreaza `resume_gateway_url` primit la ultimul READY si il
refoloseste la fiecare reconectare. Daca acel gateway regional pica
(503 la handshake pe gateway-us-east-1a), botul reincearca la infinit
acelasi host mort, cu backoff care creste pana la ~15 minute: procesul e
viu, deci systemd nu vede nimic si nici dashboardul. Singura iesire e un
IDENTIFY nou pe gateway.discord.gg, adica un restart.
"""
def __init__(self, threshold_s: float, now=time.monotonic):
self.threshold_s = threshold_s
self._now = now
self.down_since: float | None = None
def on_up(self) -> None:
self.down_since = None
def on_down(self) -> None:
# Doar prima cadere conteaza: reincercarile care esueaza nu reseteaza ceasul.
if self.down_since is None:
self.down_since = self._now()
def downtime(self) -> float:
return 0.0 if self.down_since is None else self._now() - self.down_since
def expired(self) -> bool:
# threshold <= 0 dezactiveaza watchdogul.
return self.threshold_s > 0 and self.downtime() >= self.threshold_s
def _hard_exit_after(seconds: float, code: int) -> None: # pragma: no cover - plasa de siguranta
"""Daca `close()` se blocheaza pe un socket mort, iesim oricum."""
timer = threading.Timer(seconds, lambda: os._exit(code))
timer.daemon = True
timer.start()
# --------------------------------------------------------------- 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
self.watchdog = GatewayWatchdog(config.get_float("GATEWAY_WATCHDOG_S", 300.0))
self.watchdog_tripped = False
self._watchdog_task = None
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())
self._watchdog_task = asyncio.create_task(self._watchdog_loop())
async def _watchdog_loop(self):
# Verificam des in raport cu pragul, ca sa nu adaugam intarziere peste el.
interval = max(5.0, min(30.0, self.watchdog.threshold_s / 4))
while not self.is_closed():
await asyncio.sleep(interval)
if not self.watchdog.expired():
continue
log.error(
"gateway cazut de %.0fs (prag %.0fs): ies cu codul %d ca systemd sa reporneasca",
self.watchdog.downtime(),
self.watchdog.threshold_s,
WATCHDOG_EXIT_CODE,
)
with contextlib.suppress(Exception):
alerts.alert(
"WARN",
"punte Discord: gateway cazut, restart",
f"reconectarea a esuat {self.watchdog.downtime():.0f}s la rand; ies "
f"cu codul {WATCHDOG_EXIT_CODE} pentru un IDENTIFY nou.",
"gateway-watchdog",
)
self.watchdog_tripped = True
_hard_exit_after(30.0, WATCHDOG_EXIT_CODE)
await self.close()
return
async def on_connect(self):
self.watchdog.on_up()
async def on_resumed(self):
self.watchdog.on_up()
async def on_disconnect(self):
self.watchdog.on_down()
async def on_ready(self):
self.watchdog.on_up()
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):
task = self._watchdog_task
self._watchdog_task = None
if task is not None and task is not asyncio.current_task():
task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await task
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)
# Nenul: doar asa `Restart=on-failure` reporneste puntea.
return WATCHDOG_EXIT_CODE if getattr(client, "watchdog_tripped", False) else 0
if __name__ == "__main__": # pragma: no cover
raise SystemExit(main())