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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q4uzvgm7AyJch5WH8QHRhY
This commit is contained in:
Claude Agent
2026-08-31 17:47:48 +00:00
parent c5af5f2380
commit c42b95b7db
5 changed files with 439 additions and 12 deletions

View File

@@ -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 |

View File

@@ -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)

View File

@@ -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:

View File

@@ -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"]

View File

@@ -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()