File size: 18,665 Bytes
2695555
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
"""openTARS 4.5 Endurance: arquivos anexados ao pedido.

O usuário anexa arquivos (botão 📎 na janela, /anexar no terminal, ou
arrastando o arquivo pro terminal) e a IA recebe o conteúdo junto com a
pergunta:
  * imagens: vão como imagem pra um modelo que enxerga (ou viram texto,
    descritas por um modelo de visão, se o escolhido não enxerga);
  * texto, código, CSV, JSON, logs...: o conteúdo entra na mensagem
    (cortado se for enorme);
  * PDF (pdftotext ou pypdf), DOCX e ODT: o texto é extraído;
  * pasta: a lista dos arquivos;
  * qualquer outro binário: só o nome, o tamanho e o caminho, pra IA
    poder usar o arquivo com um comando.

Este módulo é puro (só lê o arquivo; não fala com modelo nenhum).
"""

import base64
import io
import os
import re
import shutil
import subprocess
import zipfile
from dataclasses import dataclass, field
from pathlib import Path
from urllib.parse import unquote, urlparse

import tars_ambiente as ambiente

MAX_ANEXOS = 8
LIMITE_TEXTO_CHARS = 20000          # por arquivo
LIMITE_TOTAL_CHARS = 30000          # soma dos anexos de um pedido (o núcleo ajusta ao contexto da IA)
LIMITE_LEITURA_BYTES = 4_000_000    # de um arquivo de texto lê no máximo isso
LIMITE_IMAGEM_BYTES = 30_000_000
LADO_MAXIMO_IMAGEM = 1600
LIMITE_PASTA_ITENS = 100

EXT_IMAGEM = {".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp", ".tif", ".tiff"}
EXT_DOCX = {".docx"}
EXT_ODT = {".odt", ".ods", ".odp"}
EXT_BINARIAS = {
    ".zip", ".gz", ".xz", ".bz2", ".7z", ".rar", ".tar", ".deb", ".rpm", ".iso", ".exe", ".bin", ".so", ".o",
    ".mp3", ".wav", ".flac", ".ogg", ".mp4", ".mkv", ".avi", ".mov", ".webm", ".xlsx", ".xls", ".pptx", ".ppt",
    ".gguf", ".safetensors", ".pt", ".pth", ".onnx", ".sqlite", ".db", ".woff", ".woff2", ".ttf", ".otf",
}


class ErroAnexo(Exception):
    """Mensagem já pronta pra mostrar ao usuário."""


@dataclass
class Anexo:
    caminho: str
    nome: str
    tipo: str                      # imagem | texto | pdf | docx | pasta | binario
    bytes: int = 0
    texto: str = ""                # conteúdo extraído (tipo != imagem)
    imagem_b64: str = ""
    largura: int = 0
    altura: int = 0
    truncado: bool = False
    avisos: list = field(default_factory=list)

    def resumo(self):
        if self.tipo == "imagem":
            return f"{self.nome} (image {self.largura}x{self.altura})"
        if self.tipo == "pasta":
            return f"{self.nome}/ (folder)"
        return f"{self.nome} ({tamanho_legivel(self.bytes)})"


def _existe(caminho):
    """Path.exists() levanta PermissionError (Python <= 3.12) se a pasta de cima não pode ser atravessada (/root/x)."""
    try:
        return Path(caminho).exists()
    except OSError:
        return False


def tamanho_legivel(n):
    n = float(n)
    for unidade in ("B", "KB", "MB", "GB"):
        if n < 1024 or unidade == "GB":
            return f"{n:.0f} {unidade}" if unidade == "B" else f"{n:.1f} {unidade}"
        n /= 1024


# ------------------------------------------------------------
# Caminho vindo de um campo, do arrastar-e-soltar ou de um comando
# ------------------------------------------------------------

def normalizar_caminho(entrada, wsl=None):
    """Aceita 'aspas', file:///url%20codificada, ~/x, caminho com espaço
    escapado ('a\\ b', como alguns terminais colam) e, no WSL, C:\\Users\\...
    Devolve um Path (que pode não existir)."""
    t = str(entrada or "").strip()
    if len(t) >= 2 and t[0] == t[-1] and t[0] in "\"'":
        t = t[1:-1]
    elif "\\ " in t:
        t = t.replace("\\ ", " ")
    if t.lower().startswith("file://"):
        t = unquote(urlparse(t).path)
        if re.match(r"^/[A-Za-z]:/", t):  # file:///C:/x
            t = t[1:]
    wsl = ambiente.e_wsl() if wsl is None else wsl
    if ambiente.parece_caminho_windows(t) and (wsl or not _existe(Path(t))):
        t = ambiente.caminho_do_windows(t)
    return Path(os.path.expanduser(t))


# ------------------------------------------------------------
# Leitura por tipo
# ------------------------------------------------------------

def _e_binario(amostra):
    if b"\x00" in amostra:
        return True
    if not amostra:
        return False
    estranhos = sum(1 for b in amostra if b < 9 or (13 < b < 32))
    return estranhos / len(amostra) > 0.2


def _decodificar(bruto):
    if bruto[:2] in (b"\xff\xfe", b"\xfe\xff"):
        try:
            return bruto.decode("utf-16")
        except UnicodeDecodeError:
            pass
    try:
        # incremental: um caractere cortado no fim da leitura parcial (o arquivo passou do limite) não vira erro
        import codecs
        return codecs.getincrementaldecoder("utf-8-sig")().decode(bruto, final=False)
    except UnicodeDecodeError:
        return bruto.decode("latin-1")


def _cortar(texto, limite):
    if len(texto) <= limite:
        return texto, False
    return texto[:limite] + f"\n[... cut: {len(texto) - limite} more characters not shown ...]", True


def _ler_imagem(caminho, anexo):
    from PIL import Image
    if anexo.bytes > LIMITE_IMAGEM_BYTES:
        raise ErroAnexo(f"'{anexo.nome}' is too big for an image ({tamanho_legivel(anexo.bytes)}; the limit is "
                        f"{tamanho_legivel(LIMITE_IMAGEM_BYTES)}).")
    try:
        with Image.open(caminho) as img:
            img.seek(0)
            img.load()
            tem_alfa = img.mode in ("RGBA", "LA") or (img.mode == "P" and "transparency" in img.info)
            if tem_alfa:
                fundo = Image.new("RGB", img.size, (255, 255, 255))
                fundo.paste(img.convert("RGBA"), mask=img.convert("RGBA").split()[-1])
                img = fundo
            else:
                img = img.convert("RGB")
            if max(img.size) > LADO_MAXIMO_IMAGEM:
                razao = LADO_MAXIMO_IMAGEM / max(img.size)
                img = img.resize((max(1, round(img.width * razao)), max(1, round(img.height * razao))), Image.LANCZOS)
            buffer = io.BytesIO()
            img.save(buffer, format="PNG" if tem_alfa else "JPEG", **({} if tem_alfa else {"quality": 90}))
            anexo.largura, anexo.altura = img.size
    except ErroAnexo:
        raise
    except Exception as e:
        raise ErroAnexo(f"I could not open '{anexo.nome}' as an image ({type(e).__name__}).")
    anexo.imagem_b64 = base64.b64encode(buffer.getvalue()).decode("ascii")


def _texto_do_pdf(caminho):
    if shutil.which("pdftotext"):
        try:
            r = subprocess.run(["pdftotext", "-layout", "-q", str(caminho), "-"], capture_output=True, timeout=60)
            if r.returncode == 0:
                return r.stdout.decode("utf-8", "replace")
        except Exception:
            pass
    try:
        import pypdf
        leitor = pypdf.PdfReader(str(caminho))
        return "\n\n".join((p.extract_text() or "") for p in leitor.pages)
    except ImportError:
        import tars_plataforma
        raise ErroAnexo(tars_plataforma.dica_instalar("To read PDFs install poppler-utils (sudo apt install poppler-utils)."))
    except Exception as e:
        raise ErroAnexo(f"I could not read the PDF ({type(e).__name__}).")


def _texto_de_xml_zip(caminho, interno, paragrafo):
    try:
        with zipfile.ZipFile(caminho) as z:
            xml = z.read(interno).decode("utf-8", "replace")
    except Exception as e:
        raise ErroAnexo(f"I could not read the document ({type(e).__name__}).")
    xml = re.sub(paragrafo, "\n", xml)
    xml = re.sub(r"<[^>]+>", "", xml)
    for a, b in (("&amp;", "&"), ("&lt;", "<"), ("&gt;", ">"), ("&quot;", '"'), ("&apos;", "'")):
        xml = xml.replace(a, b)
    return re.sub(r"\n{3,}", "\n\n", xml).strip()


def _listar_pasta(caminho):
    itens = []
    try:
        for i, p in enumerate(sorted(caminho.iterdir(), key=lambda x: (not x.is_dir(), x.name.lower()))):
            if i >= LIMITE_PASTA_ITENS:
                itens.append(f"[... more items not shown ...]")
                break
            itens.append(p.name + ("/" if p.is_dir() else ""))
    except OSError as e:
        raise ErroAnexo(f"I could not list the folder ({e.strerror or e}).")
    return "\n".join(itens)


def carregar(entrada, wsl=None):
    """Lê um arquivo ou pasta e devolve um Anexo. ErroAnexo se não der."""
    caminho = normalizar_caminho(entrada, wsl)
    if not str(entrada or "").strip():
        raise ErroAnexo("No file given.")
    if not _existe(caminho):
        raise ErroAnexo(f"'{caminho}' does not exist.")
    try:
        caminho = caminho.resolve()
    except OSError:
        pass
    anexo = Anexo(caminho=str(caminho), nome=caminho.name or str(caminho), tipo="texto")

    if caminho.is_dir():
        anexo.tipo = "pasta"
        anexo.texto = _listar_pasta(caminho)
        return anexo
    if not caminho.is_file():
        raise ErroAnexo(f"'{caminho}' is not a regular file.")
    try:
        anexo.bytes = caminho.stat().st_size
        with open(caminho, "rb") as f:
            amostra = f.read(4096)
    except OSError as e:
        raise ErroAnexo(f"I cannot read '{anexo.nome}' ({e.strerror or e}).")

    ext = caminho.suffix.lower()
    if ext in EXT_IMAGEM:
        anexo.tipo = "imagem"
        _ler_imagem(caminho, anexo)
        return anexo
    if ext == ".pdf" or amostra.startswith(b"%PDF"):
        anexo.tipo = "pdf"
        texto = _texto_do_pdf(caminho).strip()
        if not texto:
            anexo.avisos.append("no text found (scanned PDF?)")
        anexo.texto, anexo.truncado = _cortar(texto, LIMITE_TEXTO_CHARS)
        return anexo
    if ext in EXT_DOCX:
        anexo.tipo = "docx"
        texto = _texto_de_xml_zip(caminho, "word/document.xml", r"</w:p>|<w:br\s*/>")
        anexo.texto, anexo.truncado = _cortar(texto, LIMITE_TEXTO_CHARS)
        return anexo
    if ext in EXT_ODT:
        anexo.tipo = "docx"
        texto = _texto_de_xml_zip(caminho, "content.xml", r"</text:p>|</text:h>|<text:line-break/>")
        anexo.texto, anexo.truncado = _cortar(texto, LIMITE_TEXTO_CHARS)
        return anexo
    utf16 = amostra[:2] in (b"\xff\xfe", b"\xfe\xff")  # texto UTF-16 tem NULs, mas não é binário
    if ext in EXT_BINARIAS or (_e_binario(amostra) and not utf16):
        anexo.tipo = "binario"
        anexo.avisos.append("binary file: the content is not included, use the path with a command if you need it")
        return anexo

    with open(caminho, "rb") as f:
        bruto = f.read(LIMITE_LEITURA_BYTES)
    texto = _decodificar(bruto)
    anexo.texto, anexo.truncado = _cortar(texto, LIMITE_TEXTO_CHARS)
    if anexo.bytes > LIMITE_LEITURA_BYTES:
        anexo.truncado = True
    return anexo


def carregar_varios(entradas, wsl=None, ja_anexados=()):
    """([Anexo], [mensagens de erro]) — um erro não derruba os outros.
    Respeita MAX_ANEXOS contando os que já estavam anexados."""
    prontos, erros = [], []
    ja = {a.caminho for a in ja_anexados}
    for entrada in entradas:
        if len(ja) + len(prontos) >= MAX_ANEXOS:
            erros.append(f"At most {MAX_ANEXOS} attachments per message.")
            break
        try:
            a = carregar(entrada, wsl)
        except ErroAnexo as e:
            erros.append(str(e))
            continue
        if a.caminho in ja or any(a.caminho == p.caminho for p in prontos):
            continue
        prontos.append(a)
    return prontos, erros


# ------------------------------------------------------------
# O que vai pra IA
# ------------------------------------------------------------

def _cabecalho(a):
    extras = f"; {'; '.join(a.avisos)}" if a.avisos else ""
    return f"{a.nome} (path: {a.caminho}, {a.tipo}, {tamanho_legivel(a.bytes) if a.bytes else 'folder'}{extras})"


def montar_bloco(anexos, descricoes=None, limite_total=None):
    """Texto que acompanha a pergunta. 'descricoes': {caminho: texto} das
    imagens quando o modelo não enxerga (descritas por outro modelo)."""
    if not anexos:
        return ""
    descricoes = descricoes or {}
    partes = ["[Files attached by the user to this message. Their content is below; the paths let you use them "
              "with commands if needed.]"]
    restante = limite_total if limite_total is not None else LIMITE_TOTAL_CHARS
    for a in anexos:
        if a.tipo == "imagem":
            if a.caminho in descricoes:
                partes.append(f"--- image: {_cabecalho(a)} ---\n(You cannot see images. A vision model described it:)\n"
                              f"{descricoes[a.caminho]}\n--- end of {a.nome} ---")
            else:
                partes.append(f"--- image: {_cabecalho(a)} --- (the image is attached to this message)")
            continue
        if a.tipo == "binario":
            partes.append(f"--- file: {_cabecalho(a)} ---")
            continue
        corpo = a.texto
        aviso = ""
        if len(corpo) > restante:
            corpo = corpo[:max(restante, 0)]
            aviso = "\n[... cut: the attachments of this message are too long ...]"
        restante -= len(corpo)
        if a.truncado and not aviso:
            aviso = "\n[... the file is longer than what is shown ...]"
        partes.append(f"--- file: {_cabecalho(a)} ---\n{corpo}{aviso}\n--- end of {a.nome} ---")
    return "\n\n".join(partes)


def imagens_b64(anexos):
    return [a.imagem_b64 for a in anexos if a.tipo == "imagem" and a.imagem_b64]


# ------------------------------------------------------------
# Terminal: achar caminhos numa linha ("@arquivo", arrastar e soltar)
# ------------------------------------------------------------

_ARROBA = re.compile(r"(?<!\S)@(\"[^\"]+\"|'[^']+'|\S+)")


def separar_arrobas(linha):
    """'resuma @notas.txt e @"meu doc.pdf"' -> ('resuma e', ['notas.txt', 'meu doc.pdf']),
    só dos '@caminho' que existem de fato (o resto continua no texto)."""
    achados = []

    def troca(m):
        bruto = m.group(1)
        if bruto[0] in "\"'" and bruto[-1] == bruto[0]:
            bruto = bruto[1:-1]
        if bruto and _existe(normalizar_caminho(bruto)):
            achados.append(bruto)
            return ""
        return m.group(0)

    texto = _ARROBA.sub(troca, linha)
    return re.sub(r"\s{2,}", " ", texto).strip(), achados


def linha_e_so_caminho(linha):
    """Quem arrasta um arquivo pro terminal cola só o caminho: se a linha
    inteira (com ou sem aspas) é um arquivo/pasta que existe, devolve-o."""
    t = str(linha or "").strip()
    if not t or "\n" in t or len(t) > 1000:
        return None
    candidatos = [t]
    # vários arrastados de uma vez: 'a.png' 'b.txt'
    entre_aspas = re.findall(r"'([^']+)'|\"([^\"]+)\"", t)
    if len(entre_aspas) > 1 and not re.sub(r"'[^']*'|\"[^\"]*\"|\s+", "", t):
        candidatos = [a or b for a, b in entre_aspas]
    caminhos = []
    for c in candidatos:
        if not (c.startswith(("/", "~", "file://", "'", '"', ".")) or ambiente.parece_caminho_windows(c)):
            return None
        p = normalizar_caminho(c)
        if not _existe(p):
            return None
        caminhos.append(c)
    return caminhos

# ------------------------------------------------------------
# Imagens citadas no texto do pedido ("veja home/pictures/foto.png")
# ------------------------------------------------------------
_RE_IMAGEM_NO_TEXTO = re.compile(r"""(?:"([^"\n]+\.(?:png|jpe?g|webp|gif|bmp))"|'([^'\n]+\.(?:png|jpe?g|webp|gif|bmp))'|"""
                                 r"""((?:file://)?[^\s"'<>|]+\.(?:png|jpe?g|webp|gif|bmp)))""", re.IGNORECASE)
_APELIDOS_PASTAS = {"pictures": ("Pictures", "Imagens"), "imagens": ("Imagens", "Pictures"),
                    "downloads": ("Downloads", "Transferências", "Descargas", "Téléchargements"),
                    "documents": ("Documents", "Documentos"), "documentos": ("Documentos", "Documents"),
                    "desktop": ("Desktop", "Área de Trabalho", "Escritorio", "Bureau", "Schreibtisch"),
                    "área de trabalho": ("Área de Trabalho", "Desktop"), "area de trabalho": ("Área de Trabalho", "Desktop"),
                    "home": ()}


def _resolver_sem_caixa(base, partes):
    """Anda por `partes` a partir de `base`, aceitando maiúscula/minúscula diferente e os nomes de pasta em
    português/inglês (pictures = Imagens). Volta atrás se o caminho não continuar: com ~/Documents E ~/Documentos,
    "Documents/foto.png" acha a foto que está em Documentos (antes parava na primeira). None se não achar."""
    partes = [p for p in partes if p and p != "."]
    atual = Path(base)
    if not partes:
        return atual
    parte, resto = partes[0], partes[1:]
    try:
        nomes = {p.name.lower(): p.name for p in atual.iterdir()}
    except OSError:
        return None
    opcoes = [nomes[n] for n in dict.fromkeys([parte.lower()] + [a.lower() for a in _APELIDOS_PASTAS.get(parte.lower(), ())])
              if n in nomes]
    for nome in opcoes:
        achado = _resolver_sem_caixa(atual / nome, resto)
        if achado is not None and (resto or achado.exists()):
            return achado
    return None


def resolver_caminho_citado(token):
    """Caminho existente (Path) pro que a pessoa escreveu, ou None. Aceita: absoluto, ~/x, relativo à pasta
    pessoal, "home/pictures/x.png" (sem a barra), e erro de maiúscula."""
    bruto = str(token or "").strip()
    if not bruto:
        return None
    try:
        direto = normalizar_caminho(bruto)
        if _existe(direto) and direto.is_file():
            return direto
    except Exception:
        pass
    limpo = bruto[7:] if bruto.lower().startswith("file://") else bruto
    partes = [p for p in re.split(r"[\\/]+", limpo) if p]
    casa = Path.home()
    candidatos = []
    if partes and partes[0].lower() == "home":
        candidatos.append((casa, partes[1:]))          # "home/pictures/x.png" = ~/pictures/x.png
        candidatos.append((Path("/home"), partes[1:]))
    candidatos.append((casa, partes))
    if limpo.startswith("/"):
        candidatos.insert(0, (Path("/"), partes))
    for base, resto in candidatos:
        achado = _resolver_sem_caixa(base, resto)
        if achado is not None and _existe(achado) and achado.is_file():
            return achado
    return None


def achar_imagens_no_texto(texto, maximo=3):
    """[Path] das imagens que o pedido cita e que existem (sem repetir)."""
    achadas = []
    for m in _RE_IMAGEM_NO_TEXTO.finditer(texto or ""):
        token = next(g for g in m.groups() if g)
        caminho = resolver_caminho_citado(token)
        if caminho is not None and caminho not in achadas:
            achadas.append(caminho)
        if len(achadas) >= maximo:
            break
    return achadas