Puntea trimitea doar `content`-ul mesajului; orice atasament disparea tacut, iar un mesaj fara text (doar poza) era respins ca "empty". Acum mesajul de utilizator se construieste ca blocuri, in formatul pe care CLI-ul il accepta pe `--input-format stream-json` (verificat pe CLI real: Claude descrie corect o imagine trimisa asa). - imagini png/jpeg/gif/webp -> blocuri `image` base64, max 4, max 3,5 MB brut (base64 umfla cu ~4/3, iar API-ul refuza peste ~5 MB codate) - fisiere text (mime `text/*`, `application/json`, sau extensie cunoscuta) -> continutul intra in prompt, max 4, trunchiat la 100 KB - restul (PDF, Office, arhive, svg, heic) -> doar numite, cu motivul Detalii care conteaza: - `image/jpg` si `image/png; charset=...` se normalizeaza; cand Discord nu trimite content_type cadem pe extensie - marimea se verifica de doua ori: cea declarata (ca sa nu descarcam degeaba) si cea reala dupa descarcare - nimic nu dispare tacut: ce n-a putut fi citit apare in prompt ca "Atasamente ignorate: ..."; o imagine stricata nu anuleaza restul mesajului - merge si mid-tur: o poza trimisa in timpul unui tur intra pe stdin ca steering 31 de teste noi in tests/test_attachments.py (normalizare tipuri, limite, trunchiere, erori de descarcare, integrare prin punte, steering). Suita: 426 pass. README: sectiune "Atasamente" + limitarea veche corectata (ramane doar vocea). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q4uzvgm7AyJch5WH8QHRhY
1087 lines
44 KiB
Python
1087 lines
44 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 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")))
|
|
|
|
|
|
# --------------------------------------------------------------- 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")
|
|
|
|
# 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()
|
|
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"
|
|
"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 | 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)
|
|
|
|
|
|
# --------------------------------------------------------------- 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())
|