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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user