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
This commit is contained in:
123
proxmox/lxc171-claude-agent/discord-bridge/config.py
Normal file
123
proxmox/lxc171-claude-agent/discord-bridge/config.py
Normal file
@@ -0,0 +1,123 @@
|
||||
"""Configuratie comuna a puntii Discord -> Claude Code.
|
||||
|
||||
Citeste ~/.claude-discord/env (KEY=value, tolerant la comentarii si ghilimele).
|
||||
Lane B si Lane C importa acest modul pentru cai si setari.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import pathlib
|
||||
|
||||
# Directorul de baza poate fi mutat in teste prin CLAUDE_DISCORD_DIR.
|
||||
_DEFAULT_DIR = pathlib.Path.home() / ".claude-discord"
|
||||
|
||||
STATE_DIR: pathlib.Path = pathlib.Path(os.environ.get("CLAUDE_DISCORD_DIR") or _DEFAULT_DIR)
|
||||
APPROVALS_DIR: pathlib.Path = STATE_DIR / "approvals"
|
||||
STATE_FILE: pathlib.Path = STATE_DIR / "state.json"
|
||||
LOG_DIR: pathlib.Path = STATE_DIR / "logs"
|
||||
ENV_FILE: pathlib.Path = STATE_DIR / "env"
|
||||
SETTINGS_FILE: pathlib.Path = STATE_DIR / "bot-settings.json"
|
||||
|
||||
_env: dict[str, str] = {}
|
||||
|
||||
# Valori implicite pentru cheile pe care le foloseste nucleul.
|
||||
DEFAULTS: dict[str, str] = {
|
||||
"MODEL_DEFAULT": "sonnet",
|
||||
"COST_CAP_USD_DAY": "10.0",
|
||||
"MAX_PROCS": "4",
|
||||
"TURN_TIMEOUT_S": "900",
|
||||
"IDLE_REAP_S": "1200",
|
||||
"RATE_PER_USER_PER_MIN": "10",
|
||||
"CLAUDE_BIN": "claude",
|
||||
"DEFAULT_CWD": "/workspace",
|
||||
}
|
||||
|
||||
|
||||
def parse_env(text: str) -> dict[str, str]:
|
||||
"""Parseaza un fisier de tip KEY=value. Nu arunca niciodata."""
|
||||
out: dict[str, str] = {}
|
||||
for raw in text.splitlines():
|
||||
line = raw.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
if line.startswith("export "):
|
||||
line = line[len("export "):].strip()
|
||||
if "=" not in line:
|
||||
continue
|
||||
key, _, val = line.partition("=")
|
||||
key = key.strip()
|
||||
if not key:
|
||||
continue
|
||||
val = val.strip()
|
||||
# taie comentariul de la finalul liniei doar daca valoarea nu e in ghilimele
|
||||
if val[:1] not in ("'", '"'):
|
||||
cut = val.find(" #")
|
||||
if cut >= 0:
|
||||
val = val[:cut].rstrip()
|
||||
if len(val) >= 2 and val[0] == val[-1] and val[0] in ("'", '"'):
|
||||
val = val[1:-1]
|
||||
out[key] = val
|
||||
return out
|
||||
|
||||
|
||||
def reload(base_dir: str | os.PathLike | None = None) -> dict[str, str]:
|
||||
"""Recalculeaza caile si reciteste env-ul. Returneaza dictionarul incarcat."""
|
||||
global STATE_DIR, APPROVALS_DIR, STATE_FILE, LOG_DIR, ENV_FILE, SETTINGS_FILE, _env
|
||||
if base_dir is None:
|
||||
base_dir = os.environ.get("CLAUDE_DISCORD_DIR") or _DEFAULT_DIR
|
||||
STATE_DIR = pathlib.Path(base_dir)
|
||||
APPROVALS_DIR = STATE_DIR / "approvals"
|
||||
STATE_FILE = STATE_DIR / "state.json"
|
||||
LOG_DIR = STATE_DIR / "logs"
|
||||
ENV_FILE = STATE_DIR / "env"
|
||||
SETTINGS_FILE = STATE_DIR / "bot-settings.json"
|
||||
try:
|
||||
_env = parse_env(ENV_FILE.read_text(encoding="utf-8"))
|
||||
except OSError:
|
||||
_env = {}
|
||||
return _env
|
||||
|
||||
|
||||
def get(key: str, default=None):
|
||||
"""Env-ul de pe disc, apoi mediul procesului, apoi DEFAULTS, apoi `default`."""
|
||||
if key in _env:
|
||||
return _env[key]
|
||||
if key in os.environ:
|
||||
return os.environ[key]
|
||||
if default is not None:
|
||||
return default
|
||||
return DEFAULTS.get(key, default)
|
||||
|
||||
|
||||
def get_float(key: str, default: float) -> float:
|
||||
try:
|
||||
return float(get(key, str(default)))
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def get_int(key: str, default: int) -> int:
|
||||
try:
|
||||
return int(float(get(key, str(default))))
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def get_list(key: str, default=()) -> list[str]:
|
||||
"""Lista separata prin virgula sau spatiu (allowlist-uri)."""
|
||||
raw = get(key, "")
|
||||
if not raw:
|
||||
return list(default)
|
||||
return [p for p in raw.replace(",", " ").split() if p]
|
||||
|
||||
|
||||
def ensure_dirs() -> None:
|
||||
for d in (STATE_DIR, APPROVALS_DIR, LOG_DIR):
|
||||
try:
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
reload()
|
||||
Reference in New Issue
Block a user