File size: 17,913 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
# openTARS — nucleo/elementos.py
# Clicar e escrever pelo nome: acessibilidade e, nos apps que não a oferecem, a visão da tela (o Gargantua 2).
#
# Parte do núcleo: o tars.py executa este arquivo DENTRO do próprio
# namespace (ver _carregar_nucleo), então tudo aqui é tars.<nome> e as
# partes enxergam umas às outras sem import. Os testes que trocam
# tars.<função> por uma falsa continuam valendo pra todas as partes.
# flake8: noqa

# ============================================================
# ELEMENTOS DA JANELA (árvore de acessibilidade)
# ============================================================
#
# Clicar num botão pelo NOME ("7", "=", "Salvar") em vez de print +
# coordenada: ver tars_acessibilidade.py. O print continua como plano B
# pra app que não expõe a árvore.

_acessibilidade_ligada = {"feito": False}

AVISO_SEM_ACESSIBILIDADE = (
    "The accessibility interface is not available ({motivo}). Use take_screenshot "
    "with window=<app> and click by coordinates. (To enable it: sudo apt install "
    "gir1.2-atspi-2.0 python3-gi at-spi2-core)"
)


def _dica_clique_por_nome():
    if acess.indisponivel():
        return ""
    return " To press its buttons, use click_element with the text shown on each button."


def _janela_x_do_pid(pid):
    if not pid:
        return None
    return next((j for j in listar_janelas_x() or [] if j["pid"] == pid), None)


TIMEOUT_JANELA_ACESSIVEL_SEG = 5
_abertos_sozinhos = {}  # app normalizado -> quando o openTARS tentou abrir


def _pode_abrir_sozinho(nome):
    """Abre no máximo uma vez a cada 30 s por app (uma fila de cliques não
    abre cinco calculadoras), nunca o próprio openTARS, e só se o nome
    for de um app instalado."""
    chave = normalizar(nome)
    if not chave or "tars" in chave:
        return False
    agora = time.time()
    if agora - _abertos_sozinhos.get(chave, 0) < 30:
        return False
    _abertos_sozinhos[chave] = agora
    return bool(encontrar_app(nome))


def _nota_aberto(alvo):
    return " (it was not open, so openTARS opened it first)" if alvo.get("aberto_agora") else ""


# Apps abertos que não expõem os botões (Electron/Chromium: Claude,
# Discord, VS Code, Slack...). Depois da primeira falha, click_element e
# list_elements respondem na hora com o caminho certo, em vez de a IA
# tentar nome por nome (log real: 14 cliques falhos seguidos no Claude).
_SEM_ARVORE = {}  # nome normalizado -> quando descobrimos
VALIDADE_SEM_ARVORE_SEG = 600


def _capturar_janela(nome):
    """(imagem, ox, oy, janela_x) da janela pedida, já na frente, ou None."""
    janela_x = encontrar_janela_por_nome(nome) if nome else None
    if not janela_x:
        return None
    try:
        if janela_x["id"] != janela_ativa_x():
            focar_janela_x(janela_x["id"])
            time.sleep(0.3)
        janela_x = next((j for j in listar_janelas_x() or [] if j["id"] == janela_x["id"]), janela_x)
    except Exception:
        pass
    captura = _capturar_tela()
    x0, y0 = max(0, janela_x["x"]), max(0, janela_x["y"])
    x1 = min(captura.width, janela_x["x"] + janela_x["largura"])
    y1 = min(captura.height, janela_x["y"] + janela_x["altura"])
    if x1 - x0 < 10 or y1 - y0 < 10:
        return None
    return captura.crop((x0, y0, x1, y1)), x0, y0, janela_x


# Clique pela visão (ícone sem texto): TARS_CLIQUE_POR_VISAO=0 desliga a visão nos cliques.
CLIQUE_POR_VISAO = os.environ.get("TARS_CLIQUE_POR_VISAO", "1") not in ("0", "off", "nao", "no")


def clicar_pela_visao(nome, janela):
    """Plano B do click_element: o modelo de visão olha a janela e aponta onde está 'nome'
    (botão, campo ou ícone); o openTARS clica ali. Funciona em app que não expõe
    seus botões (Electron...). None se não deu pra olhar (sem modelo de visão, sem janela)."""
    if not CLIQUE_POR_VISAO or ERRO_PYAUTOGUI or not janela:
        return None
    capturada = _capturar_janela(janela)
    if not capturada:
        return None
    imagem, ox, oy, janela_x = capturada
    ponto, _tag = localizar_com_visao(nome, imagem)
    if ponto == "ausente":
        return {"sucesso": False, "janela": janela_x["titulo"],
                "mensagem": f"Looked at '{janela_x['titulo']}' and '{nome}' is not there."}
    if not isinstance(ponto, tuple):
        _avisar_falha_da_visao()
        return None
    try:
        pyautogui.click(x=ox + ponto[0], y=oy + ponto[1])
    except Exception as e:
        return {"sucesso": False, "mensagem": f"Located '{nome}' but the click failed: {e}"}
    time.sleep(0.35)
    return {"sucesso": True, "janela": janela_x["titulo"], "elemento": nome, "mensagem": (
        f"SUCCESS: clicked where '{nome}' was located in '{janela_x['titulo']}' (x={ponto[0]}, y={ponto[1]} in "
        "the window). It was found by LOOKING at the screen, so it can be a little off: check the result "
        "(take_screenshot) before going on.")}


def _mensagem_sem_arvore(nome):
    return (
        f"'{nome}' is open, but it does not expose its buttons to the accessibility interface "
        "(common in Electron/Chromium apps such as Claude, Discord, VS Code, Slack). You CAN "
        "still use it. To write and send a message (its text box usually already has the "
        f"keyboard focus): type_in_element(text=<your text>, window='{nome}', submit=true). "
        f"To click something else: take_screenshot(window='{nome}') and click_mouse with x/y of "
        "that image plus target='<what it is>'. Do NOT use click_element or list_elements on it again, and don't ask the "
        "user to do it: do it yourself with those tools."
    )


def _digitar_na_janela(janela, texto, enviar=False):
    """Traz a janela pra frente e digita no campo que tem o foco (num app
    de chat, a caixa de mensagem). enviar=True aperta Enter no fim."""
    focada = focar_janela(janela)
    if not focada.get("sucesso"):
        return focada
    time.sleep(0.25)  # o app redesenha e devolve o foco à caixa de texto
    resultado = digitar_texto(texto or "")
    if not resultado.get("sucesso"):
        return resultado
    if enviar:
        tecla = pressionar_tecla("enter")
        if not tecla.get("sucesso"):
            return tecla
    titulo = (focada.get("janela") or {}).get("titulo") or janela
    return {"sucesso": True, "janela": titulo, "mensagem": (
        f"SUCCESS: typed the text into the focused box of '{titulo}'"
        + (" and pressed Enter to send it." if enviar else ". Use press_key('enter') to send it.")
        + " If it may have gone to the wrong place, check with take_screenshot.")}


def _marcar_sem_arvore(nome):
    if nome:
        _SEM_ARVORE[normalizar(nome)] = time.time()


def _sem_arvore(nome):
    quando = _SEM_ARVORE.get(normalizar(nome or ""))
    return quando is not None and time.time() - quando < VALIDADE_SEM_ARVORE_SEG


def _janela_acessivel(nome):
    """A janela (na árvore de acessibilidade) que a IA pediu, ou a ativa.
    Devolve (janela, erro)."""

    motivo = acess.indisponivel()
    if motivo:
        aviso = (plataforma.dica_instalar(AVISO_SEM_ACESSIBILIDADE) if plataforma.sistema() == "linux"
                 else AVISO_SEM_ACESSIBILIDADE.split(" (To enable")[0])
        return None, aviso.format(motivo=motivo)
    if nome and _sem_arvore(nome):
        return None, _mensagem_sem_arvore(nome)

    def procurar():
        janela_x, termos = None, ()
        if nome:
            termos = _termos_para_janela(nome)
            janela_x = encontrar_janela_por_nome(nome)
            # Só o título batendo (uma aba "Calculadora online" no
            # navegador) não basta pra dizer que é o app pedido.
            if janela_x and _pontuar_janela(janela_x, termos) < 2:
                janela_x = None
        else:
            ativa = janela_ativa_x()
            if ativa:
                janela_x = next((j for j in listar_janelas_x() or [] if j["id"] == ativa), None)
        return acess.achar_janela(
            pid=janela_x["pid"] if janela_x else None,
            titulo=janela_x["titulo"] if janela_x else None,
            termos=termos,
            ativa=not nome,
            ignorar_pids=_pids_protegidos(),
        )

    alvo = procurar()
    # Apps Qt/KDE (e alguns outros) só expõem a árvore com a
    # acessibilidade da sessão ligada: liga uma vez e procura de novo.
    if alvo is None and not _acessibilidade_ligada["feito"]:
        _acessibilidade_ligada["feito"] = True
        if acess.ativar():
            time.sleep(0.6)
            alvo = procurar()

    # A IA pulou o open_application ("clique no 1 da calculadora" com a
    # calculadora fechada): abre o app e espera a janela aparecer na
    # árvore, uma vez por app. Antes os cinco cliques falhavam em fila.
    aberta_no_x = bool(nome) and alvo is None and encontrar_janela_por_nome(nome) is not None
    if alvo is None and nome and not aberta_no_x and _pode_abrir_sozinho(nome):
        aberto = abrir_aplicativo(nome)
        if aberto.get("sucesso"):
            limite = time.time() + TIMEOUT_JANELA_ACESSIVEL_SEG
            while alvo is None and time.time() < limite:
                time.sleep(0.25)
                alvo = procurar()
            if alvo is not None:
                alvo = dict(alvo, aberto_agora=True)

    if alvo is None:
        if nome and (aberta_no_x or encontrar_janela_por_nome(nome) is not None):
            _marcar_sem_arvore(nome)
            return None, _mensagem_sem_arvore(nome)
        if nome:
            return None, (
                f"'{nome}' is not open, or does not expose its elements to the accessibility "
                "interface. If it is open, use take_screenshot with window=<app> and click by coordinates."
            )
        return None, (
            "No accessible window is in focus. Pass window=<app name>, or use "
            "take_screenshot and click by coordinates."
        )
    return alvo, None


def listar_elementos(janela=None, filtro=None):
    """Handler da ferramenta list_elements."""
    alvo, erro = _janela_acessivel(janela)
    if erro:
        return {"sucesso": False, "mensagem": erro}

    r = acess.listar(alvo, filtro)
    if not r["total"] and not r["textos"] and not filtro and janela:
        _marcar_sem_arvore(janela)
        return {"sucesso": False, "janela": alvo["titulo"], "mensagem": _mensagem_sem_arvore(janela)}
    if not r["total"] and not r["textos"]:
        return {
            "sucesso": False,
            "janela": alvo["titulo"],
            "mensagem": (
                f"'{alvo['titulo']}' exposes no clickable elements"
                + (f" matching '{filtro}'" if filtro else "")
                + ". Use take_screenshot with window=<app> and click by coordinates."
            ),
        }
    return {
        "sucesso": True,
        "janela": alvo["titulo"],
        "elementos": r["elementos"],
        "textos": r["textos"],
        "mensagem": (
            f"{r['total']} element(s) in '{alvo['titulo']}'{_nota_aberto(alvo)} (grouped by role) and the texts "
            "it shows. Press one with click_element(name=...)."
        ),
    }


def _clicar_pelo_retangulo(alvo, retangulo):
    """Plano B de click_element: elemento sem ação de acessibilidade, mas
    com posição conhecida. Só com X11 e só se a posição cair dentro da
    janela (GTK 4 informa posições relativas, que clicariam no lugar errado)."""
    if SESSAO_GRAFICA not in ("x11", "windows", "aqua") or ERRO_PYAUTOGUI or not retangulo:
        return False
    if not acess.coordenadas_confiaveis(alvo):
        return False
    x, y, largura, altura = retangulo
    janela_x = _janela_x_do_pid(alvo.get("pid"))
    if largura <= 0 or altura <= 0 or not janela_x:
        return False
    cx, cy = x + largura // 2, y + altura // 2
    dentro = (janela_x["x"] <= cx < janela_x["x"] + janela_x["largura"]
              and janela_x["y"] <= cy < janela_x["y"] + janela_x["altura"])
    if not dentro:
        return False
    try:
        pyautogui.click(x=cx, y=cy)
        return True
    except Exception:
        return False


def clicar_elemento(nome, janela=None, papel=None):
    """Handler da ferramenta click_element."""
    nome = (nome or "").strip()
    if not nome:
        return {"sucesso": False, "mensagem": "ERROR: 'name' is empty: pass the text shown on the element."}

    alvo, erro = _janela_acessivel(janela)
    if erro:
        if janela and _sem_arvore(janela):
            por_texto = clicar_pela_visao(nome, janela)
            if por_texto:
                return por_texto
        return {"sucesso": False, "mensagem": erro}

    r = acess.clicar(alvo, nome, papel)
    if r["sucesso"]:
        textos = r.get("textos") or []
        mensagem = f"SUCCESS: clicked {r['elemento']} in '{alvo['titulo']}'{_nota_aberto(alvo)}."
        if textos:
            mensagem += " The window now shows: " + " | ".join(textos[:8])
        return {"sucesso": True, "janela": alvo["titulo"], "elemento": r["elemento"],
                "textos": textos[:8], "mensagem": mensagem}

    if not r["achou"]:
        parecidos = acess.parecidos(alvo, nome)
        if not parecidos and janela and not acess.listar(alvo, None)["total"]:
            _marcar_sem_arvore(janela)
            por_texto = clicar_pela_visao(nome, janela)
            if por_texto and por_texto["sucesso"]:
                return por_texto
            return {"sucesso": False, "janela": alvo["titulo"], "mensagem": _mensagem_sem_arvore(janela)}
        # A árvore existe mas não tem esse nome (botão desenhado à mão,
        # canvas...): tenta achar o texto na tela antes de desistir.
        por_texto = clicar_pela_visao(nome, janela or alvo.get("titulo"))
        if por_texto and por_texto["sucesso"]:
            return por_texto
        return {
            "sucesso": False,
            "janela": alvo["titulo"],
            "mensagem": (
                f"No element named '{nome}' in '{alvo['titulo']}'."
                + (f" Some of its elements: {', '.join(parecidos)}." if parecidos else "")
                + " Use list_elements to see them all, or take_screenshot to click by coordinates."
            ),
        }

    if _clicar_pelo_retangulo(alvo, r.get("retangulo")):
        return {"sucesso": True, "janela": alvo["titulo"], "elemento": r["elemento"],
                "mensagem": f"SUCCESS: clicked {r['elemento']} in '{alvo['titulo']}' (with the mouse)."}

    return {
        "sucesso": False,
        "janela": alvo["titulo"],
        "mensagem": (
            f"Found {r['elemento']} in '{alvo['titulo']}', but it cannot be pressed through "
            "accessibility. Use take_screenshot with window=<app> and click by coordinates."
        ),
    }


def escrever_em_elemento(nome, texto, janela=None, enviar=False):
    """Handler da ferramenta type_in_element."""
    barrado = _barrar_texto_para_terminal(texto, janela)
    if barrado:
        return barrado
    alvo, erro = _janela_acessivel(janela)
    if erro:
        # App aberto sem árvore (Electron). Com o nome do campo ("Reply to
        # Claude", "Pesquisar"), o Cooper acha o campo e o openTARS clica nele; sem nome,
        # ou se não achar, digita no campo que já tem o foco.
        if janela and _sem_arvore(janela):
            if (nome or "").strip():
                clicado = clicar_pela_visao(nome.strip(), janela)
                if clicado and clicado.get("sucesso"):
                    time.sleep(0.15)
                    resultado = digitar_texto(texto or "")
                    if resultado.get("sucesso") and enviar:
                        pressionar_tecla("enter")
                    if resultado.get("sucesso"):
                        resultado["mensagem"] = (
                            f"SUCCESS: clicked the field '{clicado['elemento']}' in '{clicado['janela']}' "
                            "(found by looking at the screen) and typed the text"
                            + (" and pressed Enter." if enviar else "."))
                    return resultado
            return _digitar_na_janela(janela, texto, enviar)
        return {"sucesso": False, "mensagem": erro}

    r = acess.preencher(alvo, (nome or "").strip(), texto or "")
    if r["sucesso"]:
        if enviar:
            janela_x = _janela_x_do_pid(alvo.get("pid"))
            if janela_x:
                try:
                    focar_janela_x(janela_x["id"])
                    time.sleep(0.15)
                except Exception:
                    pass
            pressionar_tecla("enter")
        return {"sucesso": True, "janela": alvo["titulo"],
                "mensagem": f"SUCCESS: wrote the text into {r['elemento']} in '{alvo['titulo']}'"
                            + (" and pressed Enter." if enviar else ".")}

    if r.get("precisa_digitar"):
        # O campo não aceita texto pela acessibilidade: traz a janela pra
        # frente (o foco do teclado é da janela ativa) e digita.
        janela_x = _janela_x_do_pid(alvo.get("pid"))
        if janela_x:
            try:
                focar_janela_x(janela_x["id"])
                time.sleep(0.2)
            except Exception:
                pass
        resultado = digitar_texto(texto or "")
        if resultado.get("sucesso"):
            if enviar:
                pressionar_tecla("enter")
            resultado["mensagem"] = (f"SUCCESS: focused {r['elemento']} in '{alvo['titulo']}' and typed the text"
                                     + (" and pressed Enter." if enviar else "."))
        return resultado

    campos = r.get("campos") or []
    return {
        "sucesso": False,
        "janela": alvo["titulo"],
        "mensagem": (
            f"No text field named '{nome}' in '{alvo['titulo']}'."
            + (f" Its fields: {', '.join(campos)}." if campos else " It has no text field exposed.")
            + " You can also click the field and use type_text."
        ),
    }