From cb2eedac86c78ea026609234f61675f2e93a81d3 Mon Sep 17 00:00:00 2001 From: Marius Mutu Date: Mon, 31 Aug 2026 21:52:30 +0000 Subject: [PATCH] =?UTF-8?q?fix(transcribe):=20rulare=20deta=C8=99at=C4=83,?= =?UTF-8?q?=20ca=20joburile=20video=20s=C4=83=20nu=20mai=20moar=C4=83=20la?= =?UTF-8?q?=20timeout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Două linkuri Facebook trimise pe Discord n-au produs niciun răspuns: scriptul rula în interiorul turnului de chat, iar yt-dlp + ffmpeg + whisper depășeau constant DEFAULT_TIMEOUT (300s, src/claude_session.py:37). Procesul era omorât la mijlocul transcrierii, de cinci ori pe patru încercări. Efecte în cascadă, toate reparate aici: - retry-uri oarbe: linkul 1 reprocesat de două ori (notițe duplicate), linkul 2 niciodată atins - notițe cu TL;DR gol — arată „gata" în index dar nu sunt - orfani de 33MB în /tmp (trap EXIT nu rulează la SIGKILL) - userul nu primea nici rezultat, nici eroare acționabilă Schimbări: - --bg (setsid nohup) + --notify ; turnul confirmă pornirea, nu terminarea. send_text() nou în tools/discord_send_file.py, pentru că un job detașat supraviețuiește turnului și nu mai poate raporta prin on_text. - faster-whisper int8 în locul openai-whisper: 554s audio în 113s (~5x realtime) vs. peste 300s. Era deja în requirements.txt; openai-whisper nici măcar nu era declarat. cpu_threads pe core-uri fizice, nu logice. - limba implicită „auto": rularea forțată cu `ro` pe audio englezesc producea transcriere halucinată (română inventată amestecată cu CJK) - .venv/bin/python3 explicit; whisper nu există în python-ul de sistem, scriptul mergea doar când era chemat cu venv-ul activat - metadata yt-dlp pe disc, parsată o dată: descrierile Facebook conțin caractere de control care corup variabila de shell, iar `|| echo Unknown` masca eșecul - titlul sare segmentele de statistici: Facebook emite „830K views · 15K reactions | Titlul real | Pagina", iar split('|')[0] producea notițe numite 2026-08-31_807k-views-15k-reactions.md - curăță workdir-urile orfane mai vechi de 60 min la pornire .gitignore: bridge/whatsapp/auth.bak-*/ — backup-urile Baileys conțin creds.json (chei de sesiune vii), la fel ca auth/ care era deja ignorat. Verificat end-to-end pe ambele linkuri: titlu corect, limbă en auto-detectată, notiță + reindex + link, trap curăță /tmp, zero orfani. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0188YmDTUAzVvZDVh8JL8xSa --- .gitignore | 3 + scripts/transcribe_video.sh | 218 +++++++++++++++++++++++++----------- tasks/lessons.md | 19 +++- tools/discord_send_file.py | 40 ++++++- 4 files changed, 210 insertions(+), 70 deletions(-) diff --git a/.gitignore b/.gitignore index 36fc9c5..bf069b9 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,6 @@ approved-tasks.json dashboard/status.json tools/anaf-monitor/monitor.log models/ + +# Backup-uri de stare Baileys — conțin creds.json (chei de sesiune WhatsApp) +bridge/whatsapp/auth.bak-*/ diff --git a/scripts/transcribe_video.sh b/scripts/transcribe_video.sh index 92a6f44..5fbd184 100755 --- a/scripts/transcribe_video.sh +++ b/scripts/transcribe_video.sh @@ -1,119 +1,201 @@ #!/usr/bin/env bash # Descarcă un video (Facebook, YouTube etc.), extrage audio, transcrie cu Whisper. -# Usage: ./transcribe_video.sh [language] -# ./transcribe_video.sh [language] --save-kb +# +# Usage: ./transcribe_video.sh [language] [--save-kb] [--bg] [--notify ] +# +# language cod ISO (ro, en, ...) sau "auto" (implicit) pentru detecție automată. +# NU forța limba dacă nu ești sigur: `ro` pe audio englezesc face +# Whisper să halucineze text incoerent (incident 2026-08-31). +# --save-kb scrie notița în memory/kb// și reindexează. +# --bg rulează detașat și întoarce imediat controlul. Obligatoriu când +# scriptul e chemat dintr-un turn de chat: turnul are timeout de +# 300s (DEFAULT_TIMEOUT, src/claude_session.py) iar un video lung +# îl depășește, procesul e omorât la mijloc și userul nu primește +# nimic. Cu --bg turnul răspunde imediat, jobul continuă singur. +# --notify channel ID Discord unde se raportează finalizarea (util cu --bg). +# # Exemple: # ./transcribe_video.sh "https://www.facebook.com/share/v/1EdPt3q2sq/" # ./transcribe_video.sh "https://www.facebook.com/share/r/1akfPJYvTw/" ro --save-kb -# ./transcribe_video.sh "https://youtu.be/xyz" ro +# ./transcribe_video.sh "https://youtu.be/xyz" auto --save-kb --bg --notify 1471916752119009432 set -euo pipefail -URL="${1:-}" -LANG="${2:-en}" -SAVE_KB=0 - -# Parse flags -for arg in "$@"; do - if [[ "$arg" == "--save-kb" ]]; then - SAVE_KB=1 - fi -done - -WORKDIR="/tmp/transcribe_$$" PROJECT_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +# Whisper trăiește în venv, nu în python-ul de sistem. Scriptul mergea înainte +# doar din noroc, când era chemat dintr-un shell cu venv-ul deja activat. +PY="$PROJECT_ROOT/.venv/bin/python3" KB_DIR="$PROJECT_ROOT/memory/kb" +LOG_DIR="$PROJECT_ROOT/logs/transcribe" + +URL="" +LANG="auto" +SAVE_KB=0 +BG=0 +NOTIFY="" + +# --- parse argumente (poziționale: URL, apoi limba; restul flag-uri) --- +POSITIONAL=() +while [[ $# -gt 0 ]]; do + case "$1" in + --save-kb) SAVE_KB=1; shift ;; + --bg) BG=1; shift ;; + --notify) NOTIFY="${2:-}"; shift 2 ;; + -h|--help) sed -n '2,20p' "$0"; exit 0 ;; + *) POSITIONAL+=("$1"); shift ;; + esac +done +URL="${POSITIONAL[0]:-}" +LANG="${POSITIONAL[1]:-auto}" if [[ -z "$URL" ]]; then - echo "Usage: $0 [language (default: en)] [--save-kb]" + echo "Usage: $0 [language|auto] [--save-kb] [--bg] [--notify ]" exit 1 fi export PATH="/home/moltbot/bin:$PATH" +# --- mod background: re-exec detașat, întoarce imediat --- +if [[ "$BG" == "1" ]]; then + mkdir -p "$LOG_DIR" + JOB_ID="$(date +%Y%m%d-%H%M%S)-$$" + JOB_LOG="$LOG_DIR/$JOB_ID.log" + ARGS=("$URL" "$LANG") + [[ "$SAVE_KB" == "1" ]] && ARGS+=(--save-kb) + [[ -n "$NOTIFY" ]] && ARGS+=(--notify "$NOTIFY") + setsid nohup "$0" "${ARGS[@]}" > "$JOB_LOG" 2>&1 & + echo "→ Job pornit în background: $JOB_ID (pid $!)" + echo " Log: $JOB_LOG" + [[ -n "$NOTIFY" ]] && echo " Raportez pe canalul $NOTIFY la final." + exit 0 +fi + +# --- curăță workdir-uri orfane (trap-ul EXIT nu rulează la SIGKILL) --- +find /tmp -maxdepth 1 -type d -name 'transcribe_*' -mmin +60 -exec rm -rf {} + 2>/dev/null || true + +WORKDIR="/tmp/transcribe_$$" mkdir -p "$WORKDIR" trap 'rm -rf "$WORKDIR"' EXIT +notify() { + [[ -z "$NOTIFY" ]] && return 0 + "$PY" "$PROJECT_ROOT/tools/discord_send_file.py" \ + --channel "$NOTIFY" --text "$1" >/dev/null 2>&1 || true +} +fail() { + echo "Eroare: $1" + notify "❌ Transcriere eșuată pentru $URL — $1" + exit 1 +} + echo "→ Obțin informații video..." -INFO_JSON=$(yt-dlp "$URL" --dump-json --no-download -q 2>/dev/null || echo "{}") -TITLE=$(echo "$INFO_JSON" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('title','Unknown'))" 2>/dev/null || echo "Unknown") -# Trunchiază titlul la primul '|' sau la 80 caractere (Facebook posts au titlul = descriere) -TITLE_SHORT=$(echo "$TITLE" | python3 -c " -import sys -s = sys.stdin.read().strip() -if '|' in s: - s = s.split('|')[0].strip() -if len(s) > 80: - s = s[:77].rstrip() + '...' -print(s) -") -CREATOR=$(echo "$INFO_JSON" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('uploader') or d.get('channel') or '')" 2>/dev/null || echo "") -DURATION=$(echo "$INFO_JSON" | python3 -c "import json,sys; d=json.load(sys.stdin); s=d.get('duration',0); print(f'{s//60}:{s%60:02d}')" 2>/dev/null || echo "?") +# JSON-ul merge pe disc, nu printr-o variabilă de shell: descrierile Facebook conțin +# caractere de control și emoji care corup variabila (zsh: "character not in range"), +# iar `|| echo Unknown` din versiunea veche masca eșecul în loc să-l semnaleze. +yt-dlp "$URL" --dump-json --no-download -q > "$WORKDIR/info.json" 2>/dev/null || echo '{}' > "$WORKDIR/info.json" + +# Titlul, creatorul și durata într-o singură trecere. +# Facebook împachetează titlul ca "830K views · 15K reactions | Titlul real | Pagina | ..."; +# luarea oarbă a primului segment producea notițe numite "807k-views-15k-reactions" +# (incident 2026-08-31), deci sărim segmentele de statistici și pe cel egal cu numele paginii. +eval "$("$PY" - "$WORKDIR/info.json" <<'PYMETA' +import json, re, shlex, sys + +try: + d = json.load(open(sys.argv[1], encoding="utf-8")) +except Exception: + d = {} + +creator = (d.get("uploader") or d.get("channel") or "").strip() +secs = int(d.get("duration") or 0) +duration = f"{secs // 60}:{secs % 60:02d}" if secs else "?" + +raw = (d.get("title") or "Unknown").strip() +STATS = re.compile(r"^[\d.,]+\s*[KMB]?\s*(views|reactions|comments|shares|likes)\b", re.I) +parts = [p.strip() for p in raw.split("|") if p.strip()] +title = next( + (p for p in parts if not STATS.match(p) and p.lower() != creator.lower() and len(p) > 3), + parts[0] if parts else raw, +) +title = " ".join(title.split()) +if len(title) > 80: + title = title[:77].rstrip() + "..." + +for name, val in (("TITLE_SHORT", title), ("CREATOR", creator), ("DURATION", duration)): + print(f"{name}={shlex.quote(val)}") +PYMETA +)" echo "→ Descarc video: $TITLE_SHORT..." -yt-dlp "$URL" -o "$WORKDIR/video.%(ext)s" --no-playlist -q +yt-dlp "$URL" -o "$WORKDIR/video.%(ext)s" --no-playlist -q || fail "descărcarea a eșuat" VIDEO_FILE=$(ls "$WORKDIR"/video.* 2>/dev/null | head -1) -if [[ -z "$VIDEO_FILE" ]]; then - echo "Eroare: descărcarea a eșuat." - exit 1 -fi +[[ -z "$VIDEO_FILE" ]] && fail "descărcarea a eșuat (niciun fișier)" echo "→ Extrag audio..." -ffmpeg -i "$VIDEO_FILE" -vn -acodec pcm_s16le -ar 16000 -ac 1 "$WORKDIR/audio.wav" -y -loglevel error +ffmpeg -i "$VIDEO_FILE" -vn -acodec pcm_s16le -ar 16000 -ac 1 "$WORKDIR/audio.wav" -y -loglevel error \ + || fail "extragerea audio a eșuat" -echo "→ Transcriu cu Whisper (model: small, limbă: $LANG)..." -TRANSCRIPT=$(python3 -c " -import whisper -model = whisper.load_model('small') -result = model.transcribe('$WORKDIR/audio.wav', language='$LANG') -print(result['text']) -" 2>/dev/null) +# cpu_threads = core-uri FIZICE; hyperthread-urile încetinesc int8 compute-bound. +THREADS=$(lscpu -p=CORE 2>/dev/null | grep -v '^#' | sort -u | wc -l) +[[ -z "$THREADS" || "$THREADS" -lt 1 ]] && THREADS=4 + +echo "→ Transcriu cu faster-whisper (model: small, int8, ${THREADS} threads, limbă: $LANG)..." +# faster-whisper (declarat în requirements.txt) în locul openai-whisper: ~5x realtime +# pe acest CPU vs. peste 300s pentru 9 minute de audio — exact ce depășea timeout-ul. +"$PY" - "$WORKDIR/audio.wav" "$LANG" "$THREADS" > "$WORKDIR/transcript.txt" 2>"$WORKDIR/whisper.err" <<'PYEOF' || fail "transcrierea a eșuat ($(tail -1 "$WORKDIR/whisper.err" 2>/dev/null))" +import sys +from faster_whisper import WhisperModel + +wav, lang, threads = sys.argv[1], sys.argv[2], int(sys.argv[3]) +model = WhisperModel("small", device="cpu", compute_type="int8", cpu_threads=threads) +segments, info = model.transcribe(wav, language=None if lang == "auto" else lang, vad_filter=True) +text = " ".join(s.text.strip() for s in segments) +print(text) +print(f"[detected_language={info.language}]", file=sys.stderr) +PYEOF + +TRANSCRIPT=$(cat "$WORKDIR/transcript.txt") +DETECTED=$(grep -oP 'detected_language=\K\w+' "$WORKDIR/whisper.err" 2>/dev/null || echo "$LANG") +[[ -z "$TRANSCRIPT" ]] && fail "transcrierea a ieșit goală" echo "" -echo "=== $TITLE ===" +echo "=== $TITLE_SHORT ===" echo "$TRANSCRIPT" echo "" -echo "✓ Transcriere completă." +echo "✓ Transcriere completă (limbă: $DETECTED)." if [[ "$SAVE_KB" == "1" ]]; then DATE=$(date +%Y-%m-%d) - # Slug din titlu scurt: lowercase, fără diacritice, doar alfanumerice și cratime - SLUG=$(echo "$TITLE_SHORT" | python3 -c " + SLUG=$(echo "$TITLE_SHORT" | "$PY" -c " import sys, re, unicodedata -s = sys.stdin.read().strip() -s = unicodedata.normalize('NFD', s) -s = ''.join(c for c in s if unicodedata.category(c) != 'Mn') -s = s.lower() -s = re.sub(r'[^a-z0-9]+', '-', s) -s = s.strip('-')[:50] -print(s) +s = unicodedata.normalize('NFD', sys.stdin.read().strip()) +s = ''.join(c for c in s if unicodedata.category(c) != 'Mn').lower() +print(re.sub(r'[^a-z0-9]+', '-', s).strip('-')[:50]) ") + [[ -z "$SLUG" ]] && SLUG="video-$(date +%H%M%S)" - # Detectează categoria din URL if echo "$URL" | grep -qi "facebook\.com"; then - CATEGORY="facebook" - FORMAT="Reel (~${DURATION} min)" + CATEGORY="facebook"; FORMAT="Reel (~${DURATION} min)" elif echo "$URL" | grep -qi "youtube\.com\|youtu\.be"; then - CATEGORY="youtube" - FORMAT="Video (~${DURATION} min)" + CATEGORY="youtube"; FORMAT="Video (~${DURATION} min)" else - CATEGORY="media" - FORMAT="Video (~${DURATION} min)" + CATEGORY="media"; FORMAT="Video (~${DURATION} min)" fi NOTE_DIR="$KB_DIR/$CATEGORY" mkdir -p "$NOTE_DIR" NOTE_FILE="$NOTE_DIR/${DATE}_${SLUG}.md" - cat > "$NOTE_FILE" << NOTEEOF + cat > "$NOTE_FILE" </dev/null + KB_LINK="https://moltbot.tailf7372d.ts.net/echo/files.html#memory/kb/$CATEGORY/${DATE}_${SLUG}.md" + echo "✓ KB actualizat. Link: $KB_LINK" + notify "✅ Transcriere gata: **$TITLE_SHORT** (${DURATION} min, $DETECTED) +$KB_LINK +⚠️ TL;DR-ul e gol — completează-l." +else + notify "✅ Transcriere gata: **$TITLE_SHORT** (${DURATION} min, $DETECTED). Rulat fără --save-kb." fi diff --git a/tasks/lessons.md b/tasks/lessons.md index 78eedf3..121f56b 100644 --- a/tasks/lessons.md +++ b/tasks/lessons.md @@ -63,7 +63,7 @@ Lecții capturate din corectările lui Marius. Citește acest fișier la începu **Data:** 2026-04-29 **Context:** Salvam o notiță din Facebook reel în memory/kb/. Am adăugat manual o intrare în index.json cu schema greșită (`id` + `path` în loc de `file`), ceea ce a blocat notes.html pe "Se încarcă..." cu un TypeError în renderNoteCard. **Greșeala:** Am editat index.json direct, cu o schemă diferită față de ce produce update_notes_index.py. -**Regula:** Niciodată nu scriei manual în `memory/kb/index.json`. Fluxul corect: (1) creezi fișierul `.md` în `memory/kb//`, (2) rulezi `python3 tools/update_notes_index.py`. Dacă ai nevoie să salvezi o notiță din Facebook/video, folosești `scripts/transcribe_video.sh --save-kb` care face totul corect. +**Regula:** Niciodată nu scriei manual în `memory/kb/index.json`. Fluxul corect: (1) creezi fișierul `.md` în `memory/kb//`, (2) rulezi `python3 tools/update_notes_index.py`. Dacă ai nevoie să salvezi o notiță din Facebook/video, folosești `scripts/transcribe_video.sh auto --save-kb --bg --notify ` care face totul corect (`--bg` e obligatoriu dintr-un turn de chat — vezi lecția despre timeout-ul de 300s). **Când se aplică:** Orice salvare de notiță în KB (Facebook, YouTube, coaching, insights, orice). Dacă ești tentat să `json.dump` în index.json — stop, rulează scriptul. ## Verifică că modelul/tool-ul numit chiar are capabilitatea ÎNAINTE de a planifica în jurul lui @@ -86,3 +86,20 @@ Lecții capturate din corectările lui Marius. Citește acest fișier la începu **Greșeala:** Am optimizat pentru „să se audă ceva" fără să întreb ce dimensiune e prioritară pentru user. Vocea clonată E produsul; schimbarea ei e regresia maximă percepută. **Regula:** Când un input parțial invalid ajunge la un sistem cu identitate configurată de user (voce, persona, stil), remediază conținutul ca să treacă (transliterare, sanitizare), nu comuta pe altă identitate. Fallback pe alt engine/voce doar la eșec TEHNIC (server picat), nu la eșec de conținut. **Când se aplică:** Orice pipeline TTS/persona cu fallback. Implementare: `fold_ro_diacritics` în tools/tts.py + push_text în src/voice/tts_stream.py. + +## Munca mai lungă de 300s nu are ce căuta într-un turn de chat — rulează detașat și raportează pe canal +**Data:** 2026-08-31 +**Context:** Marius a trimis două linkuri de video Facebook pe Discord. Niciunul n-a produs un răspuns. În jurnal: `Claude CLI timed out after 300s`, de cinci ori, pe patru încercări diferite. `scripts/transcribe_video.sh` rula în interiorul turnului, iar yt-dlp + ffmpeg + openai-whisper pe 9 minute de audio depășeau constant `DEFAULT_TIMEOUT` (`src/claude_session.py:37`). Procesul era omorât la mijlocul Whisper-ului. +**Greșeala:** Am tratat o sarcină cu durată nemărginită (descărcare rețea + inferență ML) ca pe un apel sincron într-un turn conversațional. Efectele în cascadă au fost mai rele decât timeout-ul în sine: +- **Retry-uri oarbe pe linkul greșit** — la reîncercare am reprocesat linkul 1 de două ori (două notițe duplicate) și n-am procesat niciodată linkul 2. Când mai multe linkuri eșuează, verifică *care* a eșuat înainte să reiei. +- **Documente pe jumătate** — scriptul scrie template-ul cu TL;DR gol, iar rezumatul îl completez eu *după* ce scriptul întoarce. Procesul omorât ⇒ notițe cu TL;DR gol, care arată „gata" în index dar nu sunt. +- **Orfani pe disc** — `trap ... EXIT` nu rulează la SIGKILL; rămâneau 33MB în `/tmp/transcribe_$$`. +- **Eșec tăcut la nivel de user** — Marius n-a primit nici rezultat, nici eroare acționabilă. +**Regula:** Dacă un pas poate depăși ~2-3 minute, nu-l rula în turn. Pornește-l detașat (`setsid nohup`), întoarce imediat un confirm cu job id + cale de log, și raportează finalizarea pe canal (`tools/discord_send_file.py --text`). Turnul confirmă *pornirea*, nu *terminarea*. +**Corolare descoperite la fix:** +- **Nu forța limba.** Rularea cu `ro` pe audio englezesc a produs transcriere halucinată (română inventată amestecată cu CJK). Implicit e acum `auto`; forțează doar dacă ești sigur. +- **Alege motorul înainte de a ridica timeout-ul.** faster-whisper int8 face 554s de audio în 113s (~5x realtime); openai-whisper depășea 300s pe același fișier. `faster-whisper` era deja în `requirements.txt`, openai-whisper nici măcar nu era declarat. Un motor de 5x a fost fix mai bun decât un timeout mai mare. +- **Folosește `.venv/bin/python3` explicit în scripturi shell.** Scriptul chema `python3` bare, iar `whisper` nu există în python-ul de sistem — mergea doar din noroc, când era chemat cu venv-ul activat. +- **Nu trece JSON mare printr-o variabilă de shell.** Descrierile Facebook conțin caractere de control care corup variabila (`character not in range`); scrie-l pe disc și parsează fișierul o singură dată. `|| echo Unknown` masca eșecul. +- **Titlurile Facebook încep cu statistici,** nu cu titlul: `830K views · 15K reactions | Titlul real | Pagina`. `split('|')[0]` producea notițe numite `2026-08-31_807k-views-15k-reactions.md`. Sari segmentele de statistici și pe cel egal cu numele paginii. +**Când se aplică:** Orice transcriere video/audio, OCR pe fișiere mari, scraping multi-pagină, build sau inferență ML pornită dintr-un mesaj de chat. Implementare: `scripts/transcribe_video.sh` (`--bg`, `--notify`) + `send_text()` în `tools/discord_send_file.py`. diff --git a/tools/discord_send_file.py b/tools/discord_send_file.py index c8198a9..bfeb8e1 100644 --- a/tools/discord_send_file.py +++ b/tools/discord_send_file.py @@ -58,13 +58,47 @@ def send_file(channel_id: str, path: str, content: str | None = None, filename: return {"ok": False, "error": str(e)} +def send_text(channel_id: str, content: str) -> dict: + """Trimite un mesaj text pe canalul Discord dat, via REST API. + + De ce există pe lângă `send_file`: joburile lungi rulate detașat (ex. + `scripts/transcribe_video.sh --bg`) supraviețuiesc turnului de chat care + le-a pornit, deci nu mai au cum să raporteze prin `on_text`. Au nevoie de + o cale directă către canal, exact ca attachment-urile. + + Returns: {"ok": True, "message_id": "..."} sau {"ok": False, "error": "..."} + """ + token = get_secret("discord_token") + if not token: + return {"ok": False, "error": "discord_token lipsește din keyring (echo-core)."} + + try: + with httpx.Client(timeout=30) as client: + resp = client.post( + f"https://discord.com/api/v10/channels/{channel_id}/messages", + headers={"Authorization": f"Bot {token}"}, + json={"content": content[:2000]}, + ) + if resp.status_code not in (200, 201): + return {"ok": False, "error": f"HTTP {resp.status_code}: {resp.text[:300]}"} + return {"ok": True, "message_id": resp.json().get("id")} + except Exception as e: + return {"ok": False, "error": str(e)} + + if __name__ == "__main__": parser = argparse.ArgumentParser(description="Trimite un fișier direct pe un canal Discord") parser.add_argument("--channel", required=True, help="Channel ID Discord") - parser.add_argument("--file", required=True, help="Cale către fișierul de trimis") - parser.add_argument("--text", default=None, help="Text opțional care însoțește fișierul") + parser.add_argument("--file", default=None, help="Cale către fișierul de trimis (opțional)") + parser.add_argument("--text", default=None, help="Text; singur = mesaj simplu, cu --file = caption") args = parser.parse_args() - result = send_file(args.channel, args.file, content=args.text) + if not args.file and not args.text: + parser.error("dă cel puțin --file sau --text") + + if args.file: + result = send_file(args.channel, args.file, content=args.text) + else: + result = send_text(args.channel, args.text) print(result) sys.exit(0 if result.get("ok") else 1)