From c42b95b7dbe271816942ad1bce1304f0708bcee5 Mon Sep 17 00:00:00 2001 From: Claude Agent Date: Mon, 31 Aug 2026 17:47:48 +0000 Subject: [PATCH] feat(discord-bridge): imagini si fisiere text din Discord ajung la Claude 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) Claude-Session: https://claude.ai/code/session_01Q4uzvgm7AyJch5WH8QHRhY --- .../discord-bridge/README.md | 27 +- .../lxc171-claude-agent/discord-bridge/bot.py | 152 ++++++++++- .../discord-bridge/runner.py | 11 +- .../discord-bridge/tests/test_attachments.py | 238 ++++++++++++++++++ .../discord-bridge/tests/test_bot.py | 23 +- 5 files changed, 439 insertions(+), 12 deletions(-) create mode 100644 proxmox/lxc171-claude-agent/discord-bridge/tests/test_attachments.py diff --git a/proxmox/lxc171-claude-agent/discord-bridge/README.md b/proxmox/lxc171-claude-agent/discord-bridge/README.md index ef158b2..90b5db0 100644 --- a/proxmox/lxc171-claude-agent/discord-bridge/README.md +++ b/proxmox/lxc171-claude-agent/discord-bridge/README.md @@ -72,6 +72,27 @@ echivalenta. Orice alt mesaj din canal pleaca la Claude ca prompt, ca inainte. Un fir de Discord = o sesiune Claude. Canalul principal are si el sesiunea lui, cea implicita. Subsolul fiecarui raspuns arata modelul, durata si costul. +### Atasamente + +Pozele si fisierele text trimise pe Discord ajung la Claude in acelasi tur cu mesajul. +Un mesaj **doar** cu atasament, fara text, e valid — nu mai e respins ca gol. Merge si +mid-tur: o poza trimisa in timpul unui tur intra pe stdin ca steering, nu deschide tur nou. + +| Tip | Ce se intampla | Limite | +|-----|----------------|--------| +| Imagini `png`, `jpeg`, `gif`, `webp` | Devin blocuri `image` — Claude le vede | max 4/mesaj, max 3,5 MB fiecare | +| Fisiere text (`.txt`, `.md`, `.log`, `.csv`, `.json`, `.sql`, `.py`, `.sh`, …) | Continutul e inserat in prompt | max 4/mesaj, max 100 KB fiecare (peste atat, trunchiat) | +| Orice altceva (PDF, Office, arhive, `svg`, `heic`) | Doar numit in prompt, cu motivul | — | + +Nimic nu dispare tacut: ce n-a putut fi citit (prea mare, tip neacceptat, descarcare +esuata) apare la finalul promptului intr-o linie „Atasamente ignorate: …", deci Claude +stie ca ai trimis ceva si poate cere altceva. O imagine stricata nu anuleaza restul +mesajului. + +Limita de 3,5 MB e pe octetii bruti fiindca base64 umfla cu ~4/3, iar API-ul refuza +imaginile peste ~5 MB codate. Marimea e verificata de doua ori: intai cea declarata de +Discord (ca sa nu descarcam degeaba), apoi cea reala, dupa descarcare. + ### Despre `/cleanup` `KillMode=control-group` opreste arborele serviciului la restart, dar **nu prinde ce s-a @@ -348,7 +369,9 @@ baza pe ele ca pe o bariera. Lista `NEVER_KILL` din `cleanup.py` protejeaza infrastructura sesiunii (systemd, sshd, tmux, code-server) — deci un proces cu un asemenea nume in linia de comanda nu va fi oprit niciodata, chiar daca e orfan. -- **Fara voce, imagini sau atasamente** catre Claude in v1. +- **Fara voce.** Mesajele audio nu ajung la Claude. Imaginile si fisierele text + ajung (vezi „Atasamente" mai sus); PDF, Office, arhive si celelalte tipuri sunt + doar numite in prompt, nu citite. - **Fara dashboard web** — exista deja pe MoltBot, puntea nu-l duplica. - **Un singur container.** Daca LXC 171 e oprit, puntea e oprita. Nu are redundanta si nu e in HA. @@ -359,7 +382,7 @@ baza pe ele ca pe o bariera. | Fisier | Ce e | Lane | |--------|------|------| -| `bot.py` | adaptorul Discord: allowlist, comenzi, butoane | A | +| `bot.py` | adaptorul Discord: allowlist, comenzi, butoane, atasamente | A | | `session_store.py` | `state.json`: scriere atomica, lock per fir, PID reuse | A | | `runner.py` | proces persistent per fir, stdin JSONL, reaper | A | | `stream.py` | parser tolerant de stream JSONL | A | diff --git a/proxmox/lxc171-claude-agent/discord-bridge/bot.py b/proxmox/lxc171-claude-agent/discord-bridge/bot.py index 31cf0e5..eb395a9 100644 --- a/proxmox/lxc171-claude-agent/discord-bridge/bot.py +++ b/proxmox/lxc171-claude-agent/discord-bridge/bot.py @@ -12,6 +12,7 @@ runner/limits/render) e consumat ca atare, nu duplicat. from __future__ import annotations import asyncio +import base64 import contextlib import io import logging @@ -133,6 +134,146 @@ def thread_key(message) -> str: 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. @@ -495,7 +636,8 @@ class Bridge: return "rejected" content = (_attr(message, "content", "") or "").strip() - if not content: + attachments = _attr(message, "attachments", None) or [] + if not content and not attachments: return "empty" tid = thread_key(message) @@ -504,12 +646,14 @@ class Bridge: 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(content) + await proc.send(prompt) except Exception: log.exception("steering esuat pe firul %s", tid) else: @@ -517,7 +661,7 @@ class Bridge: await self.react(message, "➡️") return "steered" - return await self.run_turn(message, tid, content) + return await self.run_turn(message, tid, prompt) # ------------------------------------------------------------- comenzi async def handle_slash(self, interaction, name: str, **opts) -> str: @@ -743,7 +887,7 @@ class Bridge: 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: + 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) diff --git a/proxmox/lxc171-claude-agent/discord-bridge/runner.py b/proxmox/lxc171-claude-agent/discord-bridge/runner.py index 788957b..ed28d9b 100644 --- a/proxmox/lxc171-claude-agent/discord-bridge/runner.py +++ b/proxmox/lxc171-claude-agent/discord-bridge/runner.py @@ -80,9 +80,10 @@ def build_cmd( return cmd -def user_message(text: str) -> str: +def user_message(content: str | list[dict]) -> str: + blocks = [{"type": "text", "text": content}] if isinstance(content, str) else content return json.dumps( - {"type": "user", "message": {"role": "user", "content": [{"type": "text", "text": text}]}}, + {"type": "user", "message": {"role": "user", "content": blocks}}, ensure_ascii=False, ) @@ -199,11 +200,11 @@ class ClaudeProcess: await res # ------------------------------------------------------------- mesaje - async def send(self, text: str) -> None: + async def send(self, content: str | list[dict]) -> None: """Trimite un mesaj de utilizator pe stdin (si mid-tur: asta e steering-ul).""" if not self.alive or self.proc is None or self.proc.stdin is None: raise TurnFailed("procesul claude nu e viu") - self.proc.stdin.write((user_message(text) + "\n").encode("utf-8")) + self.proc.stdin.write((user_message(content) + "\n").encode("utf-8")) await self.proc.stdin.drain() self.last_active = time.monotonic() @@ -222,7 +223,7 @@ class ClaudeProcess: async def run_turn( self, - prompt: str, + prompt: str | list[dict], on_event: Callable[[Any], Awaitable[None]] | None = None, timeout: float | None = None, ) -> TurnOutcome: diff --git a/proxmox/lxc171-claude-agent/discord-bridge/tests/test_attachments.py b/proxmox/lxc171-claude-agent/discord-bridge/tests/test_attachments.py new file mode 100644 index 0000000..ee46fff --- /dev/null +++ b/proxmox/lxc171-claude-agent/discord-bridge/tests/test_attachments.py @@ -0,0 +1,238 @@ +"""Atasamente Discord -> blocuri de continut pentru Claude. + +Imaginile ajung blocuri `image` (base64), fisierele text sunt inserate in prompt, +restul sunt doar numite. Zero retea: `FakeAttachment.read()` returneaza octeti locali. +""" + +from __future__ import annotations + +import base64 + +import pytest + +import bot +import runner as runner_mod +from tests.test_bot import ( # noqa: F401 (bridge/store/allowed sunt fixturi) + FakeAttachment, FakeBrokenAttachment, FakeMessage, allowed, bridge, +) + +PNG = b"\x89PNG\r\n\x1a\n" + b"x" * 40 + + +def image(name="poza.png", data=PNG, content_type="image/png", size=None): + return FakeAttachment(name, data, content_type, size) + + +async def build(content="", attachments=()): + msg = FakeMessage(content, attachments=list(attachments)) + return await bot.build_user_content(msg, content) + + +def text_block(blocks) -> str: + return blocks[-1]["text"] + + +# ------------------------------------------------------------------ normalizare +@pytest.mark.parametrize( + "content_type,filename,expected", + [ + ("image/png", "a.png", "image/png"), + ("image/jpeg", "a.jpg", "image/jpeg"), + ("image/jpg", "a.jpg", "image/jpeg"), # nestandard, normalizat + ("image/png; charset=utf-8", "a.png", "image/png"), + ("IMAGE/PNG", "a.png", "image/png"), + ("", "a.webp", "image/webp"), # rezerva pe extensie + ("application/octet-stream", "a.gif", "image/gif"), + ("image/svg+xml", "a.svg", None), # nesuportat de API + ("image/heic", "a.heic", None), + ("application/pdf", "a.pdf", None), + ("", "a.bin", None), + ], +) +def test_tipul_imaginii_e_normalizat_si_filtrat(content_type, filename, expected): + assert bot.image_media_type(content_type, filename) == expected + + +def test_fisierele_text_sunt_recunoscute_dupa_mime_sau_extensie(): + assert bot.is_text_attachment("text/plain", "x.txt") + assert bot.is_text_attachment("application/json", "x.json") + assert bot.is_text_attachment("", "eroare.log") + assert bot.is_text_attachment("application/octet-stream", "script.py") + assert not bot.is_text_attachment("application/pdf", "manual.pdf") + assert not bot.is_text_attachment("", "arhiva.zip") + + +# ------------------------------------------------------------------ constructie +@pytest.mark.asyncio +async def test_fara_atasamente_ramane_text_simplu(): + assert await build("salut") == "salut" + + +@pytest.mark.asyncio +async def test_imaginea_devine_bloc_base64_iar_textul_e_ultimul(): + blocks = await build("ce vezi?", [image()]) + assert [b["type"] for b in blocks] == ["image", "text"] + src = blocks[0]["source"] + assert src["media_type"] == "image/png" + assert base64.b64decode(src["data"]) == PNG + assert text_block(blocks) == "ce vezi?" + + +@pytest.mark.asyncio +async def test_imagine_fara_text_primeste_instructiune_implicita(): + blocks = await build("", [image()]) + assert "Analizeaza imaginea" in text_block(blocks) + + +@pytest.mark.asyncio +async def test_maxim_patru_imagini_restul_sunt_raportate(): + atts = [image(f"p{i}.png") for i in range(6)] + blocks = await build("hai", atts) + assert sum(1 for b in blocks if b["type"] == "image") == bot.MAX_IMAGE_ATTACHMENTS + assert "p4.png" in text_block(blocks) and "p5.png" in text_block(blocks) + + +@pytest.mark.asyncio +async def test_imaginea_prea_mare_e_sarita_fara_descarcare(): + att = image("uriasa.png", size=bot.MAX_IMAGE_BYTES + 1) + blocks = await build("hai", [att]) + assert isinstance(blocks, str) # nimic lizibil ramas + assert "uriasa.png" in blocks + assert att.reads == 0 # nu descarcam ce stim ca nu incape + + +@pytest.mark.asyncio +async def test_marimea_reala_e_recontrolata_dupa_descarcare(): + """Discord poate declara mai putin decat trimite; contam octetii primiti.""" + att = image("mincinoasa.png", data=b"y" * (bot.MAX_IMAGE_BYTES + 10), size=10) + blocks = await build("hai", [att]) + assert att.reads == 1 + assert isinstance(blocks, str) and "mincinoasa.png" in blocks + + +@pytest.mark.asyncio +async def test_fisierul_text_e_inserat_in_prompt(): + att = FakeAttachment("eroare.log", b"ORA-03113 end-of-file", "text/plain") + blocks = await build("ce inseamna?", [att]) + body = text_block(blocks) + assert "ce inseamna?" in body + assert "eroare.log" in body and "ORA-03113" in body + + +@pytest.mark.asyncio +async def test_fisierul_text_prea_mare_e_trunchiat_nu_aruncat(): + data = b"a" * (bot.MAX_TEXT_BYTES + 500) + blocks = await build("vezi", [FakeAttachment("mare.log", data, "text/plain")]) + body = text_block(blocks) + assert "trunchiat" in body + assert len(body) < len(data) + + +@pytest.mark.asyncio +async def test_text_binar_nu_arunca_exceptie(): + att = FakeAttachment("stricat.txt", b"\xff\xfe\x00binar", "text/plain") + blocks = await build("vezi", [att]) + assert "stricat.txt" in text_block(blocks) + + +@pytest.mark.asyncio +async def test_imagini_si_text_impreuna(): + atts = [image(), FakeAttachment("note.md", b"# titlu", "text/markdown")] + blocks = await build("analizeaza", atts) + assert [b["type"] for b in blocks] == ["image", "text"] + assert "# titlu" in text_block(blocks) + + +@pytest.mark.asyncio +async def test_atasamentul_nesuportat_e_numit_nu_ignorat_tacut(): + blocks = await build("uite", [FakeAttachment("manual.pdf", b"%PDF", "application/pdf")]) + assert isinstance(blocks, str) + assert "manual.pdf" in blocks and "uite" in blocks + + +@pytest.mark.asyncio +async def test_eroarea_de_descarcare_nu_pica_turul(): + att = FakeBrokenAttachment("poza.png", PNG, "image/png") + blocks = await build("vezi", [att]) + assert isinstance(blocks, str) + assert "poza.png" in blocks + + +@pytest.mark.asyncio +async def test_o_imagine_buna_supravietuieste_langa_una_stricata(): + atts = [FakeBrokenAttachment("rea.png", PNG, "image/png"), image("buna.png")] + blocks = await build("vezi", atts) + assert sum(1 for b in blocks if b["type"] == "image") == 1 + assert "rea.png" in text_block(blocks) + + +# ------------------------------------------------------------------ transport +def test_blocurile_ajung_intacte_in_mesajul_de_pe_stdin(): + blocks = [ + {"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": "QQ=="}}, + {"type": "text", "text": "ce vezi?"}, + ] + import json + + payload = json.loads(runner_mod.user_message(blocks)) + assert payload["message"]["content"] == blocks + + +def test_textul_simplu_ramane_un_singur_bloc_text(): + import json + + payload = json.loads(runner_mod.user_message("salut")) + assert payload["message"]["content"] == [{"type": "text", "text": "salut"}] + + +# ---------------------------------------------------- integrare prin punte +@pytest.mark.asyncio +async def test_mesaj_cu_imagine_trece_prin_punte_pana_la_cli(bridge, monkeypatch): + """Blocurile construite in bot ajung neatinse la `proc.run_turn`.""" + vazut = {} + + async def fake_turn(prompt, on_event=None, timeout=None): + vazut["prompt"] = prompt + return runner_mod.TurnOutcome( + result=bot.stream_mod.Result(0.01, 10, False, 1, text="ok") + ) + + msg = FakeMessage("ce vezi?", attachments=[image()]) + proc = bridge.runner.get(str(msg.channel.id)) + monkeypatch.setattr(proc, "run_turn", fake_turn) + + assert await bridge.handle_message(msg) == "ok" + blocks = vazut["prompt"] + assert [b["type"] for b in blocks] == ["image", "text"] + assert base64.b64decode(blocks[0]["source"]["data"]) == PNG + + +@pytest.mark.asyncio +async def test_mesaj_doar_cu_atasament_nu_mai_e_gol(bridge): + """Inainte, un mesaj fara text era respins ca 'empty' si poza se pierdea.""" + msg = FakeMessage("", attachments=[image()]) + assert await bridge.handle_message(msg) == "ok" + + +@pytest.mark.asyncio +async def test_mesaj_complet_gol_ramane_respins(bridge): + assert await bridge.handle_message(FakeMessage("")) == "empty" + + +@pytest.mark.asyncio +async def test_steering_mid_tur_trimite_si_imaginea(bridge, monkeypatch): + """O poza trimisa in timpul unui tur intra pe stdin, nu deschide tur nou.""" + trimis = {} + tid = "200" + proc = bridge.runner.get(tid) + + async def fake_send(content): + trimis["content"] = content + + monkeypatch.setattr(proc, "send", fake_send) + monkeypatch.setattr(type(proc), "alive", property(lambda self: True)) + proc.inflight = True + + msg = FakeMessage("uite eroarea", attachments=[image()]) + assert await bridge.handle_message(msg) == "steered" + assert [b["type"] for b in trimis["content"]] == ["image", "text"] diff --git a/proxmox/lxc171-claude-agent/discord-bridge/tests/test_bot.py b/proxmox/lxc171-claude-agent/discord-bridge/tests/test_bot.py index ecad48e..5754be6 100644 --- a/proxmox/lxc171-claude-agent/discord-bridge/tests/test_bot.py +++ b/proxmox/lxc171-claude-agent/discord-bridge/tests/test_bot.py @@ -67,9 +67,30 @@ class FakeGuild: self.id = gid +class FakeAttachment: + """Minimul din `discord.Attachment` pe care il atinge puntea.""" + + def __init__(self, filename, data=b"", content_type="", size=None): + self.filename = filename + self.content_type = content_type + self.size = len(data) if size is None else size + self._data = data + self.reads = 0 + + async def read(self): + self.reads += 1 + return self._data + + +class FakeBrokenAttachment(FakeAttachment): + async def read(self): + raise RuntimeError("retea picata") + + class FakeMessage: def __init__(self, content="salut", *, author=None, channel=None, guild=None, - webhook_id=None, mid="m1"): + webhook_id=None, mid="m1", attachments=None): + self.attachments = list(attachments or []) self.content = content self.author = author if author is not None else FakeUser() self.channel = channel if channel is not None else FakeChannel()