"""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) 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") if bool(_attr(author, "bot", False)): return Decision(False, "bot") uid = str(_attr(author, "id", "")) if self_id is not None and uid == str(self_id): return Decision(False, "propriul mesaj") 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", "help") # Optiunile slash traduse in argumentele pe care cmd_* le stiu deja. SLASH_FLAGS = {"new": ("fork", "--fork"), "cleanup": ("force", "--force")} 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 _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 "" # --------------------------------------------------------------------- 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 # ------------------------------------------------------------ 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/Deny. Fara discord.py (sau fara Lane B) 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("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)." 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) log.warning( "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:` schimba directorul de lucru\n" "`/model model:` schimba modelul firului\n" "`/status` starea firului · `/stop` opreste turul curent\n" "`/cleanup [force:True]` procese lasate in urma\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) await self.say( message.channel, "🧵 Sesiune noua pornita din contextul celei curente (`--fork`)." if fork else "🧵 Sesiune noua, curata.", ) async def cmd_cd(self, message, tid: str, cmd: Command) -> None: if not cmd.args: await self.say(message.channel, "Foloseste `/cd 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"cost azi : ${self.limits.cost_today():.4f} / ${cap:.2f}" + (" ⛔ 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) 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] = [] loop = self.render.loop_for(str(tid)) async def on_event(ev) -> None: 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)) 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]).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())