Files
ROMFASTSQL/proxmox/lxc171-claude-agent/discord-bridge/bot.py
Claude Agent d466f358ce feat(discord-bridge): punte Discord -> Claude Code pe LXC 171
Implementeaza planul claude-master-plan-discord-bridge-20260830 (15 taskuri,
3 lane-uri paralele) — un bot subtire discord.py peste CLI-ul `claude`, cu
proces persistent per fir alimentat pe stdin cu --input-format stream-json.

Nucleu: runner (proces persistent + reaper 20min + respawn --resume), stream
(parser tolerant), session_store (scriere atomica, lock per fir, detectare PID
reuse, recovery), limits (max 4 procese, timeout tur, rate per user, plafon cost
pe zi), render (un loop de editare per canal, interval adaptiv).

Adaptor: allowlist guild/canal/user fail-closed cu respingerea webhook-urilor,
comenzi !new/!cd/!model/!status/!stop/!cleanup, cost si model in subsolul
fiecarui raspuns. Mesajul sosit in timpul unui tur devine steering, nu tur nou.

Securitate: hook PreToolUse fail-closed care cere confirmare in Discord pentru
operatiuni ireversibile, wrapper `infra` cu lista explicita de hosturi. Deny
rules raman strat cosmetic, nu bariera (verificat: /usr/bin/ssh trece pe langa).

Ops: alerte email pe conventia repo-ului, !cleanup pentru orfani, unit systemd
user cu KillMode=control-group si limite de memorie, install.sh idempotent.

Verificat: 275 teste fara retea/Discord/API (10.8s), identic cu si fara
discord.py instalat; e2e pe CLI real confirma steering-ul mid-tur (mesaj la 6s
intr-un tool call de 25s schimba raspunsul final).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B29CApsP1JkSdjYaGaHpE7
2026-08-30 10:44:39 +00:00

704 lines
27 KiB
Python

"""Adaptorul Discord al puntii: allowlist, comenzi, steering, aprobari.
Impartirea e intentionata:
* `Bridge` -- toata logica, fara discord.py. Primeste obiecte "mesaj" duck-typed,
deci se poate testa integral fara retea si fara Discord.
* `BridgeClient` -- invelisul subtire peste `discord.Client`, doar dispecerat.
T1 (allowlist) si T11 (model + cost) traiesc aici. Nucleul (config/stream/session_store/
runner/limits/render) e consumat ca atare, nu duplicat.
"""
from __future__ import annotations
import asyncio
import contextlib
import io
import logging
import os
import pathlib
import time
from dataclasses import dataclass, field
import 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")
PREFIX = "!"
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")
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)")
guild = _attr(message, "guild")
gid = _attr(guild, "id") if guild is not None else _attr(message, "guild_id")
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(message)) & channels):
return Decision(False, f"canal neautorizat {thread_key(message)}")
if uid not in users:
return Decision(False, f"utilizator neautorizat {uid}")
return ACCEPTED
# -------------------------------------------------------------------- comenzi
@dataclass(frozen=True)
class Command:
name: str
args: list[str] = field(default_factory=list)
@property
def rest(self) -> str:
return " ".join(self.args)
def parse_command(content: str) -> Command | None:
text = (content or "").strip()
if not text.startswith(PREFIX):
return None
body = text[len(PREFIX):].strip()
if not body:
return None
parts = body.split()
return Command(parts[0].lower(), parts[1:])
# ------------------------------------------------------------------ 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)
command = parse_command(content)
if command is not None:
return await self.handle_command(message, tid, command)
# 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_command(self, message, tid: str, cmd: Command) -> str:
handler = getattr(self, f"cmd_{cmd.name}", None)
if handler is None:
await self.say(message.channel, f"Comanda `!{cmd.name}` nu exista. `!help` le listeaza.")
return "unknown-command"
await handler(message, tid, cmd)
return f"cmd:{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` sesiune noua din contextul curent\n"
"`!cd <cale>` schimba directorul de lucru\n"
"`!model <sonnet|opus>` schimba modelul firului\n"
"`!status` starea firului · `!stop` opreste turul curent\n"
"`!cleanup [--force]` procese lasate in urma",
)
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 <{'|'.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')}",
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")
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._started = False
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()]
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())