Download app/nucleo/controle.py from OpenTARS/openTARS: direct link, hf CLI and curl.
- Browser
- Download file 38.9 kB
-
https://huggingface.co/OpenTARS/openTARS/resolve/main/app/nucleo/controle.py
- Command line
-
hf download hf://OpenTARS/openTARS/app/nucleo/controle.py
-
curl -L -o controle.py https://huggingface.co/OpenTARS/openTARS/resolve/main/app/nucleo/controle.py
38.9 kB
| # openTARS — nucleo/controle.py | |
| # Terminal, informações do PC, mouse (mira, arrasto, conferência) e teclado. | |
| # | |
| # 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 | |
| # ============================================================ | |
| # OUTRAS FERRAMENTAS | |
| # ============================================================ | |
| # Padrões de comando destrutivo o suficiente pra merecer uma | |
| # confirmação antes de rodar — a IA (inclusive um modelo pequeno, que | |
| # erra mais) manda qualquer comando pro terminal sem nenhuma trava | |
| # hoje, e um comando mal interpretado nessa lista pode apagar dados ou | |
| # derrubar o sistema sem chance de desfazer. Não é uma lista exaustiva | |
| # de tudo que pode dar errado — é uma rede de segurança pros casos mais | |
| # óbvios e mais caros. | |
| _PADROES_COMANDO_PERIGOSO = [ | |
| r"\brm\b.*\s-[a-zA-Z]*[rR]", # rm -r/-rf/-fr/-R: apaga pastas inteiras | |
| r"\brm\b.*--recursive", | |
| r"\bfind\b.*\s-delete\b", | |
| r"\bfind\b.*-exec\s+(sudo\s+)?rm\b", # find ... -exec rm | |
| r"\brm\b[^|;&\n]*[*?\[]", # rm com curinga (*.pdf): apaga vários de uma vez | |
| r"\bxargs\b.*\brm\b", | |
| r"\bshred\b", | |
| r"\bgit\b.*\b(reset\s+--hard|clean\s+-[a-z]*f)", | |
| r"\bdd\b.*\bif=", | |
| r"\bmkfs\b", | |
| r"\bwipefs\b", | |
| r"\bfdisk\b", | |
| r"\bparted\b", | |
| r">\s*/dev/(sd|nvme|hd)", | |
| r"\bshutdown\b", | |
| r"\breboot\b", | |
| r"\bpoweroff\b", | |
| r"\bhalt\b", | |
| r"\bchmod\b.*-R.*\s/(\s|$)", | |
| r"\bchown\b.*-R.*\s/(\s|$)", | |
| r":\(\)\s*\{\s*:\s*\|\s*:\s*&?\s*\}\s*;\s*:", # fork bomb | |
| r"\bmv\b[^|;&\n]*\s/dev/null\b", # mv algo /dev/null: some com o arquivo | |
| r"\bmv\b\s+(-\S+\s+)*(/|/\*|~|~/|\$HOME)\s", # mv da raiz ou da pasta pessoal inteira pra outro lugar | |
| ] | |
| _CONFIRMAR_COMANDOS_PERIGOSOS = ( | |
| os.environ.get("TARS_SEM_CONFIRMACAO_PERIGOSOS", "0") != "1" | |
| ) | |
| # Função (comando) -> bool que a interface gráfica registra pra | |
| # perguntar numa janela. None = pergunta no terminal com input(). | |
| confirmar_comando_hook = None | |
| # 5.0: rotina agendada em andamento (dict da agenda) ou None. Dentro de uma rotina NÃO há pergunta na tela: o comando | |
| # arriscado só roda se a pessoa pré-aprovou essa categoria ao criar a rotina; senão a rotina PARA e avisa. | |
| ROTINA_ATUAL = None | |
| ROTINA_BLOQUEOU = [] # comandos que pararam a rotina atual | |
| # 5.1: modo de permissão da conversa no terminal. "perguntar" (o padrão), "plano" (só descreve: nada que mude alguma coisa | |
| # roda; ferramentas que só olham continuam) e "automatico" (edições de arquivo dentro da pasta do projeto sem perguntar). | |
| # NENHUM modo dispensa a pergunta antes de apagar, enviar ou instalar. | |
| MODOS_PERMISSAO = ("perguntar", "plano", "automatico") | |
| MODO_PERMISSAO = "perguntar" | |
| PASSOS_DO_PLANO = [] # o que o modo plano deixou de fazer, mostrado no fim do pedido | |
| APROVADOS_NA_SESSAO = set() # comandos (texto exato) liberados "sempre nesta sessão"; nunca os de apagar em massa | |
| def _comando_e_perigoso(comando): | |
| comando = str(comando or "") | |
| if any(re.search(padrao, comando, re.IGNORECASE | re.DOTALL) for padrao in _PADROES_COMANDO_PERIGOSO): | |
| return True | |
| # 5.0: enviar/publicar e instalar/mudar o sistema também perguntam antes (apagar um arquivo nomeado, `rm a.txt`, | |
| # segue sem pergunta de propósito; apagar em massa já está acima/abaixo); numa rotina, qualquer ação dessas | |
| # categorias passa pela pré-aprovação. | |
| categorias = agenda_lib.categorias_do_comando(comando) | |
| if categorias & {"enviar", "instalar"}: | |
| return True | |
| return ROTINA_ATUAL is not None and bool(categorias) | |
| def pode_aprovar_na_sessao(comando): | |
| """"Sempre nesta sessão" só vale para o que pergunta por ser envio ou instalação (git push, apt install...). O que bate | |
| com um padrão destrutivo (rm -rf, mkfs, dd...) pergunta toda vez.""" | |
| comando = str(comando or "") | |
| return not any(re.search(p, comando, re.IGNORECASE | re.DOTALL) for p in _PADROES_COMANDO_PERIGOSO) | |
| def _texto_visivel(comando, limite=600): | |
| """O comando inteiro como vai ser mostrado na confirmação: sem caracteres de controle (\\r, ESC) que escondem | |
| o resto, e sem cortar no meio (um ';' depois do 200º caractere ficava fora da tela).""" | |
| vis = "".join(ch if ch.isprintable() or ch == "\n" else f"\\x{ord(ch):02x}" for ch in str(comando)) | |
| return vis if len(vis) <= limite else vis[:limite] + f" … (+{len(vis) - limite})" | |
| _CLASSES_TERMINAL = ("terminal", "xterm", "konsole", "alacritty", "kitty", "tilix", "terminator", "urxvt", "rxvt", | |
| "foot", "wezterm", "st-256color", "xfce4-terminal", "lxterminal", "qterminal", "guake", "yakuake", | |
| "ptyxis", "console", "tmux") | |
| def _janela_e_terminal(janela=None): | |
| """A janela (a pedida, ou a que está em foco) é um terminal? Texto digitado nela vira comando de shell.""" | |
| try: | |
| todas = listar_janelas_x() or [] | |
| if janela: | |
| alvo = encontrar_janela_por_nome(janela, todas) if "encontrar_janela_por_nome" in globals() else None | |
| achadas = [alvo] if alvo else [] | |
| else: | |
| ativa = janela_ativa_x() | |
| achadas = [j for j in todas if j["id"] == ativa] | |
| return any(any(c in (j.get("classe", "") + " " + j.get("titulo", "")).lower() for c in _CLASSES_TERMINAL) | |
| for j in achadas if j) | |
| except Exception: | |
| return False | |
| def _barrar_texto_para_terminal(texto, janela=None): | |
| """Digitar `rm -rf ~` numa janela de terminal e apertar Enter é o mesmo que executar_terminal: passa pela | |
| mesma pergunta. Devolve um resultado de erro se a pessoa negou; None se pode seguir.""" | |
| texto = str(texto or "") | |
| if not texto.strip() or not _comando_e_perigoso(texto) or not _janela_e_terminal(janela): | |
| return None | |
| if _confirmar_comando_perigoso(texto): | |
| return None | |
| return {"sucesso": False, "mensagem": "Typing cancelled: it would run a risky command in a terminal and the user did not confirm it."} | |
| def _confirmar_comando_perigoso(comando): | |
| """Pede confirmação explícita no terminal antes de rodar um | |
| comando que bateu com um padrão destrutivo conhecido. Pode ser | |
| desligado (por conta e risco do usuário) com a variável de | |
| ambiente TARS_SEM_CONFIRMACAO_PERIGOSOS=1, útil pra rodar o TARS | |
| de forma não-interativa.""" | |
| if not _CONFIRMAR_COMANDOS_PERIGOSOS: | |
| return True | |
| if ROTINA_ATUAL is not None: | |
| liberado = agenda_lib.autorizado(ROTINA_ATUAL, comando) | |
| LOG.info("rotina=%s comando_perigoso liberado=%s comando=%s", ROTINA_ATUAL.get("nome"), liberado, | |
| _resumir_para_log(comando)) | |
| if not liberado: | |
| ROTINA_BLOQUEOU.append(comando) | |
| cancelar_resposta() # a rotina para aqui: não tenta outro jeito | |
| return liberado | |
| if comando in APROVADOS_NA_SESSAO: | |
| LOG.info("comando_perigoso liberado_na_sessao=True comando=%s", _resumir_para_log(comando)) | |
| return True | |
| parar_girando() # o símbolo de "trabalhando" não pode redesenhar por cima da pergunta | |
| imprimir_caixa( | |
| "⚠ " + t("perigo.titulo"), | |
| [_texto_visivel(comando), t("perigo.explicacao")], | |
| cor_titulo=Cor.VERMELHO, | |
| ) | |
| if confirmar_comando_hook is not None: | |
| # Interface gráfica: não há terminal pra digitar "s", então ela | |
| # registra aqui uma função que pergunta numa caixa de diálogo. | |
| try: | |
| confirmado = bool(confirmar_comando_hook(comando)) | |
| except Exception: | |
| confirmado = False | |
| elif threading.current_thread() is not threading.main_thread(): | |
| # Pedido rodando fora da thread principal, sem janela: o input() | |
| # brigaria com o do prompt. Sem ninguém pra confirmar: não roda. | |
| print(c(t("perigo.sem_confirmacao_rotina"), Cor.AMARELO)) | |
| confirmado = False | |
| else: | |
| sessao = pode_aprovar_na_sessao(comando) | |
| try: | |
| resposta = input(c(t("perigo.pergunta_sessao" if sessao else "perigo.pergunta") + " ", Cor.AMARELO)).strip() | |
| except (EOFError, OSError): | |
| resposta = "" | |
| confirmado = normalizar(resposta) in _vocab()["respostas_sim"] | |
| if sessao and normalizar(resposta) in _vocab()["respostas_sempre"]: | |
| APROVADOS_NA_SESSAO.add(comando) | |
| confirmado = True | |
| LOG.info( | |
| "comando_perigoso confirmado=%s comando=%s", | |
| confirmado, | |
| _resumir_para_log(comando), | |
| ) | |
| return confirmado | |
| def _env_filho(**extra): | |
| """Ambiente de um programa lançado por nós: a chave da nuvem (TARS_CHAVE_API) não vai junto, senão | |
| `env` no terminal a mostraria à IA — e ao que ela conversa.""" | |
| e = {k: v for k, v in os.environ.items() if k != "TARS_CHAVE_API"} | |
| e.update(extra) | |
| return e | |
| def executar_terminal(comando): | |
| if _comando_e_perigoso(comando) and not _confirmar_comando_perigoso(comando): | |
| return { | |
| "sucesso": False, | |
| "mensagem": ( | |
| "Command cancelled: it matched a potentially destructive pattern " | |
| "and the user did not confirm it." | |
| ), | |
| } | |
| return _executar_terminal_de_fato(comando) | |
| # Programas de tela cheia: precisam de um terminal de verdade. Rodando sem terminal (stdin em /dev/null, saída num | |
| # arquivo) eles falham com "Error opening terminal" e o modelo concluía que o programa não estava instalado (16/10, htop). | |
| _PROGRAMAS_DE_TELA = {"htop", "btop", "top", "atop", "nvtop", "iotop", "glances", "vim", "vi", "nvim", "nano", "emacs", | |
| "less", "more", "man", "watch", "mc", "ranger", "nmtui", "alsamixer", "tmux", "screen", "cmatrix", | |
| "ncdu", "bpytop", "nethogs", "iftop"} | |
| _TERMINAIS = (("x-terminal-emulator", ["-e", "bash", "-c"]), ("gnome-terminal", ["--", "bash", "-c"]), | |
| ("kgx", ["--", "bash", "-c"]), ("konsole", ["-e", "bash", "-c"]), ("xfce4-terminal", ["-x", "bash", "-c"]), | |
| ("tilix", ["-e", "bash", "-c"]), ("xterm", ["-e", "bash", "-c"])) | |
| def programa_de_tela(comando): | |
| """O nome do programa se o comando é um programa de tela cheia rodado sozinho (htop, vim arquivo, sudo nano x), | |
| ou None. Com pipe, redirecionamento, ';' ou '&&' não é: aí a saída vai para outro lugar. 'top -b' é texto.""" | |
| if re.search(r"[|<>;&`$]", comando): | |
| return None | |
| try: | |
| partes = shlex.split(comando) | |
| except ValueError: | |
| return None | |
| while partes and (partes[0] in ("sudo", "env", "nice", "ionice") or re.match(r"^\w+=", partes[0]) or | |
| (partes[0].startswith("-") and len(partes) > 1)): | |
| partes = partes[1:] | |
| if not partes: | |
| return None | |
| nome = os.path.basename(partes[0]) | |
| if nome not in _PROGRAMAS_DE_TELA: | |
| return None | |
| if nome == "top" and any(a.startswith("-") and "b" in a for a in partes[1:]): | |
| return None | |
| return nome | |
| def _abrir_em_janela_de_terminal(comando, nome): | |
| if not (os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY")): | |
| return {"sucesso": False, "mensagem": ( | |
| f"{nome} is a full-screen terminal program and there is no graphical session to open a terminal window. " | |
| "Use a non-interactive command instead (for example 'top -bn1', 'free -h', 'ps aux --sort=-%mem | head').")} | |
| # fica aberta se der erro, para a pessoa ler; se o programa sair normalmente (q no htop), a janela fecha | |
| roteiro = f"{comando}; codigo=$?; [ $codigo -ne 0 ] && read -rp 'Enter: ' _; exit $codigo" | |
| for programa, argumentos in _TERMINAIS: | |
| if shutil.which(programa): | |
| try: | |
| subprocess.Popen([programa] + argumentos + [roteiro], stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, | |
| stderr=subprocess.DEVNULL, start_new_session=True, env=_env_filho()) | |
| except OSError: | |
| continue | |
| return {"sucesso": True, "mensagem": ( | |
| f"{nome} needs a terminal, so it was opened in a terminal window ({programa}) where the user can see and " | |
| "use it. Its screen is not captured here. Tell the user it is open in that window.")} | |
| return {"sucesso": False, "mensagem": ( | |
| f"{nome} is a full-screen terminal program and no terminal emulator was found to open it. " | |
| "Use a non-interactive command instead (for example 'top -bn1' or 'free -h').")} | |
| def _executar_terminal_de_fato(comando): | |
| # 5.2: fora do Linux, os comandos de bash que o Gargantua aprendeu rodam no mini-shell em Python (tars_shell), com a | |
| # saída no formato GNU do treino. No Windows o resto não roda; no macOS vai para o shell do sistema. | |
| if plataforma.sistema() != "linux": | |
| if shell_lib.suportado(comando): | |
| return shell_lib.executar(comando) | |
| if plataforma.sistema() == "windows": | |
| return {"sucesso": False, "mensagem": ( | |
| "This command is not available on Windows in openTARS. Only these work: " | |
| + ", ".join(sorted(shell_lib.COMANDOS)) + " (with ~, quotes, &&, |, > and >>). " | |
| "Use one of them, or another tool (open_application, search_web).")} | |
| nome = programa_de_tela(comando) | |
| if nome: | |
| return _abrir_em_janela_de_terminal(comando, nome) | |
| # "firefox &" ou "nohup x &": o programa fica rodando e segurava a | |
| # saída aberta, e o openTARS esperava o tempo limite inteiro à toa. | |
| if re.search(r"&\s*$", comando.strip()) and not comando.strip().endswith("&&"): | |
| try: | |
| filho = subprocess.Popen( | |
| comando, shell=True, stdin=subprocess.DEVNULL, | |
| stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, | |
| start_new_session=True, env=_env_filho(), | |
| ) | |
| threading.Thread(target=filho.wait, daemon=True).start() # recolhe o filho quando ele terminar (sem zumbi) | |
| time.sleep(0.4) # morreu na hora (programa que não existe)? então não foi iniciado | |
| codigo = filho.poll() | |
| if codigo not in (None, 0): | |
| return {"sucesso": False, "codigo": codigo, | |
| "mensagem": f"The command exited right away with code {codigo}, so it was not started."} | |
| return {"sucesso": True, "mensagem": "Command started in the background."} | |
| except Exception as e: | |
| return {"sucesso": False, "mensagem": str(e)} | |
| try: | |
| # A saída vai pra ARQUIVOS e só o shell é esperado: um programa que o comando abriu (xdg-open, chrome, | |
| # code .) herda a saída e a segurava aberta até o tempo limite, que então o matava; e uma saída enorme | |
| # (`yes`) ficava toda na memória até o fim. | |
| with tempfile.TemporaryFile("w+b") as arq_saida, tempfile.TemporaryFile("w+b") as arq_erro: | |
| processo = subprocess.Popen( | |
| comando, | |
| shell=True, | |
| stdout=arq_saida, | |
| stderr=arq_erro, | |
| # Sem terminal pra digitar: sudo/apt que pedem senha ou "S/n" | |
| # falham na hora em vez de travar até o tempo limite. | |
| stdin=subprocess.DEVNULL, | |
| start_new_session=True, | |
| env=_env_filho(DEBIAN_FRONTEND="noninteractive"), | |
| ) | |
| limite = time.time() + TIMEOUT_TERMINAL_COMANDO | |
| while True: | |
| try: | |
| processo.wait(timeout=0.25) | |
| break | |
| except subprocess.TimeoutExpired: | |
| if _cancelar.is_set() or time.time() > limite: | |
| try: | |
| os.killpg(processo.pid, 15) | |
| except Exception: | |
| processo.kill() | |
| try: | |
| processo.wait(timeout=3) | |
| except subprocess.TimeoutExpired: # ignorou o SIGTERM: SIGKILL | |
| try: | |
| os.killpg(processo.pid, 9) | |
| except Exception: | |
| processo.kill() | |
| try: | |
| processo.wait(timeout=3) | |
| except subprocess.TimeoutExpired: | |
| pass | |
| if _cancelar.is_set(): | |
| return {"sucesso": False, "mensagem": "Command interrupted by the user."} | |
| raise subprocess.TimeoutExpired(comando, TIMEOUT_TERMINAL_COMANDO) | |
| def _ponta(arq, limite_chars): | |
| tamanho = arq.seek(0, os.SEEK_END) | |
| arq.seek(max(0, tamanho - limite_chars * 4)) # só o final (até 4 bytes por caractere) | |
| return arq.read().decode("utf-8", errors="replace")[-limite_chars:] | |
| saida, erro_saida = _ponta(arq_saida, LIMITE_STDOUT_CHARS), _ponta(arq_erro, LIMITE_STDERR_CHARS) | |
| resultado = subprocess.CompletedProcess(comando, processo.returncode, saida, erro_saida) | |
| return { | |
| "sucesso": resultado.returncode == 0, | |
| "codigo": resultado.returncode, | |
| "stdout": resultado.stdout[-LIMITE_STDOUT_CHARS:], | |
| "stderr": resultado.stderr[-LIMITE_STDERR_CHARS:], | |
| "mensagem": ( | |
| "Command finished." | |
| if resultado.returncode == 0 | |
| else "Command finished with an error." | |
| ), | |
| } | |
| except subprocess.TimeoutExpired: | |
| return { | |
| "sucesso": False, | |
| "mensagem": ( | |
| f"The command ran for more than {TIMEOUT_TERMINAL_COMANDO}s and was stopped. " | |
| "If it needs to run for a long time, suggest that the user runs it in a terminal." | |
| ), | |
| } | |
| except Exception as e: | |
| return { | |
| "sucesso": False, | |
| "mensagem": str(e), | |
| } | |
| # O print enviado à IA é reduzido (ex: 2560x1440 -> 1280x720) e ela | |
| # responde com coordenadas DESSA imagem. Sem converter de volta, o | |
| # clique cairia no lugar errado (metade do caminho, numa tela 2x maior). | |
| # Atualizado a cada take_screenshot; 1.0 enquanto não houver print. | |
| # Com print de uma janela só, ox/oy guardam onde ela começa na tela. | |
| _escala_print = {"x": 1.0, "y": 1.0, "ox": 0, "oy": 0, "largura": None, "altura": None, "definido": False} | |
| def _escala_tela_inteira(): | |
| """Quanto o print da tela inteira é reduzido (ex: 2.0 numa tela de | |
| 2560px com prints de 1280px). Não depende de já ter tirado print.""" | |
| try: | |
| largura = pyautogui.size()[0] | |
| except Exception: | |
| return 1.0 | |
| return largura / min(largura, LARGURA_MAXIMA_SCREENSHOT) if largura else 1.0 | |
| def _usar_quadro_tela_inteira(): | |
| """Volta as coordenadas de clique pro sistema do print de tela | |
| inteira. Chamado sempre que o openTARS informa posições nesse | |
| sistema (janelas abertas, tamanho da tela) ou quando a tela muda | |
| (abrir/focar janela): um recorte de janela antigo deixaria o clique | |
| com deslocamento errado.""" | |
| escala = _escala_tela_inteira() | |
| _escala_print.update(x=escala, y=escala, ox=0, oy=0, largura=None, altura=None, definido=True) | |
| def _para_tela(x, y): | |
| """Coordenadas do último print (as que a IA vê) -> pixels reais. | |
| Sem print nenhum ainda, vale o sistema do print de tela inteira (o | |
| tamanho de tela que o prompt informa à IA).""" | |
| if not _escala_print["definido"]: | |
| _usar_quadro_tela_inteira() | |
| return ( | |
| int(round(_escala_print["ox"] + float(x) * _escala_print["x"])), | |
| int(round(_escala_print["oy"] + float(y) * _escala_print["y"])), | |
| ) | |
| def _para_print(x, y): | |
| """Pixels reais da tela -> coordenadas do último print.""" | |
| if not _escala_print["definido"]: | |
| _usar_quadro_tela_inteira() | |
| return ( | |
| int(round((x - _escala_print["ox"]) / _escala_print["x"])), | |
| int(round((y - _escala_print["oy"]) / _escala_print["y"])), | |
| ) | |
| # ------------------------------------------------------------ | |
| # 2.9.2: mouse preciso (mirar, conferir, arrastar) — lógica em tars_mouse.py | |
| # ------------------------------------------------------------ | |
| PAUSA_HOVER_SEG = 0.12 # o app desenha o "hover" antes da captura de antes | |
| ESPERA_DEPOIS_CLIQUE_SEG = 0.45 | |
| RAIO_VERIFICAR_CLIQUE = 140 # px reais em volta do clique | |
| DURACAO_PASSO_ARRASTO_SEG = 0.018 | |
| PAUSA_SEGURAR_SEG = 0.18 # botão apertado antes de sair do lugar | |
| PAUSA_SOLTAR_SEG = 0.3 # no destino, antes de soltar (o app mostra onde vai cair) | |
| def _capturar_regiao(bbox=None): | |
| """Captura rápida pra comparar antes/depois (bbox em px reais), ou | |
| None se não der (Wayland, sem tela). Só serve pra conferir.""" | |
| if SESSAO_GRAFICA == "wayland": | |
| return None | |
| try: | |
| from PIL import ImageGrab | |
| return ImageGrab.grab(bbox=bbox) if bbox else ImageGrab.grab() | |
| except Exception: | |
| return None | |
| def _tamanho_real(): | |
| try: | |
| largura, altura = pyautogui.size() | |
| return int(largura), int(altura) | |
| except Exception: | |
| return None | |
| def _conferir(acao, x, y): | |
| """Faz a ação (clique, arrasto) e diz o que mudou na tela: "perto" do | |
| ponto, "longe" dele, "nada", ou None se não deu pra conferir.""" | |
| tamanho = _tamanho_real() | |
| bbox = mouse.recorte_em_volta(int(x), int(y), RAIO_VERIFICAR_CLIQUE, *tamanho) if tamanho else None | |
| antes_perto = _capturar_regiao(bbox) if bbox else None | |
| antes_tudo = _capturar_regiao() if antes_perto is not None else None | |
| acao() | |
| if antes_perto is None: | |
| return None | |
| # Em vez de esperar o tempo todo, olha em 3 momentos e sai assim que a tela reagir. | |
| perto = None | |
| for fatia in (0.3, 0.3, 0.4): | |
| time.sleep(ESPERA_DEPOIS_CLIQUE_SEG * fatia) | |
| perto = mouse.mudanca(antes_perto, _capturar_regiao(bbox)) | |
| if mouse.mudou(perto): | |
| break | |
| if mouse.mudou(perto): | |
| return "perto" | |
| if mouse.mudou(mouse.mudanca(antes_tudo, _capturar_regiao())): | |
| return "longe" | |
| return "nada" if perto is not None else None | |
| def _ponto_por_acessibilidade(nome, janela): | |
| """Centro do elemento na tela pela árvore de acessibilidade (sem | |
| clicar), ou None. Só no X11 e com coordenadas de tela confiáveis.""" | |
| if SESSAO_GRAFICA not in ("x11", "windows", "aqua") or acess.indisponivel(): | |
| return None | |
| try: | |
| alvo, erro = _janela_acessivel(janela) | |
| if erro or not alvo or not acess.coordenadas_confiaveis(alvo): | |
| return None | |
| achado = acess.localizar(alvo, nome) | |
| except Exception: | |
| return None | |
| if not achado: | |
| return None | |
| rotulo, (x, y, largura, altura) = achado | |
| tamanho = _tamanho_real() | |
| cx, cy = x + largura // 2, y + altura // 2 | |
| if tamanho and not (0 <= cx < tamanho[0] and 0 <= cy < tamanho[1]): | |
| return None | |
| return cx, cy, f"found {rotulo} through accessibility" | |
| def _mirar_bruto(x=None, y=None, alvo=None, janela=None): | |
| """Onde clicar, em px REAIS da tela: (tx, ty, como) ou (None, None, erro). | |
| - só x/y: o ponto do último print, convertido; | |
| - x/y + alvo: a visão (o Gargantua 2) olha a tela e acha o alvo exato perto do ponto | |
| (sem ele, o ponto vai pro centro do elemento mais perto); | |
| - só alvo: acessibilidade e, por fim, a visão.""" | |
| alvo = (alvo or "").strip() | |
| tem_ponto = x is not None and y is not None | |
| if tem_ponto and not alvo: | |
| tx, ty = _para_tela(x, y) | |
| return tx, ty, "" | |
| if not tem_ponto and not alvo: | |
| return None, None, "ERROR: pass x/y (pixels of the last screenshot) or 'target' (what to click)." | |
| if not tem_ponto: | |
| achado = _ponto_por_acessibilidade(alvo, janela) | |
| if achado: | |
| return achado | |
| try: | |
| if janela: | |
| capturada = _capturar_janela(janela) | |
| if not capturada: | |
| return None, None, f"No open window looks like '{janela}'. Use list_windows." | |
| imagem, ox, oy, janela_x = capturada | |
| else: | |
| imagem, ox, oy = _capturar_tela().convert("RGB"), 0, 0 | |
| except Exception as e: | |
| if tem_ponto: | |
| tx, ty = _para_tela(x, y) | |
| return tx, ty, "" | |
| return None, None, f"ERROR capturing the screen: {e}" | |
| if CLIQUE_POR_VISAO: | |
| # A visão é a única a olhar a tela; com x/y dados pela IA, só vale perto deles | |
| perto = None | |
| if tem_ponto: | |
| tx0, ty0 = _para_tela(x, y) | |
| perto = (tx0 - ox, ty0 - oy) | |
| achado_cooper = mirar_com_visao(alvo, imagem, perto) | |
| if achado_cooper == "ausente" and not tem_ponto: | |
| return None, None, (f"Looked at the screen and '{alvo}' is not there. Check list_windows / " | |
| "list_elements, or bring the right window to the front.") | |
| if achado_cooper and achado_cooper != "ausente": | |
| return ox + achado_cooper[0], oy + achado_cooper[1], achado_cooper[2] | |
| _avisar_falha_da_visao() | |
| if tem_ponto: | |
| tx, ty = _para_tela(x, y) | |
| lx, ly = tx - ox, ty - oy | |
| nx, ny = mouse.encaixar(imagem, lx, ly) | |
| como = "moved onto the nearest element" if (nx, ny) != (lx, ly) else "" | |
| return ox + nx, oy + ny, como | |
| if not visao_instalada(): | |
| return None, None, (f"Could not find '{alvo}': it is not in the accessibility tree and no vision model is " | |
| "installed (ollama pull ProjectOpenTARS/Gargantua-2). Pass x/y from a screenshot.") | |
| return None, None, (f"The vision model could not find '{alvo}' ({motivo_da_falha_da_visao()}). " | |
| "Pass x/y from a screenshot.") | |
| def _frase_conferencia(resultado): | |
| if resultado == "perto": | |
| return " The screen changed where it clicked." | |
| if resultado == "longe": | |
| return " The screen changed (not right at the click point)." | |
| if resultado == "nada": | |
| return (" WARNING: nothing changed on screen after the click, so it probably MISSED. Don't say " | |
| "it worked: call it again with target='<what you want to click>' (the vision model aims), " | |
| "or use click_element.") | |
| return "" | |
| MARGEM_QUINA_PX = 3 # o pyautogui trava tudo se o ponteiro encostar numa das 4 quinas (fail-safe) | |
| def _longe_da_quina(px, py): | |
| """Mantém o ponto a MARGEM_QUINA_PX da borda: coordenada inventada ou fora da tela | |
| mandava o ponteiro pra uma quina, e dali em diante TODO comando de mouse/teclado | |
| falhava com FailSafeException até alguém mexer o mouse.""" | |
| tamanho = _tamanho_real() | |
| if not tamanho or px is None or py is None: | |
| return px, py | |
| m = MARGEM_QUINA_PX | |
| return max(m, min(tamanho[0] - 1 - m, px)), max(m, min(tamanho[1] - 1 - m, py)) | |
| def _mirar(x=None, y=None, alvo=None, janela=None): | |
| """_mirar_bruto com o ponto sempre dentro da tela e longe das quinas.""" | |
| tx, ty, como = _mirar_bruto(x, y, alvo, janela) | |
| if tx is not None: | |
| tx, ty = _longe_da_quina(tx, ty) | |
| return tx, ty, como | |
| def clicar_mouse(botao="left", x=None, y=None, alvo=None, janela=None, duplo=False): | |
| """Handler de click_mouse (clicks=2: duplo clique): mira (tars_mouse), clica | |
| e confere se a tela mudou.""" | |
| botao = str(botao or "left").strip().lower().replace("-click", "").replace("_click", "") | |
| botao = botao if botao in ("left", "right", "middle") else "left" | |
| try: | |
| if x is None and y is None and not (alvo or "").strip(): | |
| if duplo: | |
| pyautogui.doubleClick() | |
| else: | |
| pyautogui.click(button=botao) | |
| return {"sucesso": True, "mensagem": ("Double click done." if duplo else | |
| f"Clicked the {botao} mouse button.")} | |
| tx, ty, como = _mirar(x, y, alvo, janela) | |
| if tx is None: | |
| return {"sucesso": False, "mensagem": como} | |
| pyautogui.moveTo(tx, ty, duration=DURACAO_MOVER_MOUSE_SEG) | |
| time.sleep(PAUSA_HOVER_SEG) | |
| if duplo: | |
| conferido = _conferir(lambda: pyautogui.doubleClick(x=tx, y=ty), tx, ty) | |
| else: | |
| conferido = _conferir(lambda: pyautogui.click(x=tx, y=ty, button=botao), tx, ty) | |
| px, py = _para_print(tx, ty) | |
| o_que = "Double-clicked" if duplo else f"Clicked the {botao} mouse button" | |
| mensagem = f"{o_que} at ({px}, {py})" + (f": {como}" if como else "") + "." | |
| resultado = {"sucesso": True, "mensagem": mensagem + _frase_conferencia(conferido), "x": px, "y": py} | |
| if conferido == "nada": | |
| resultado["sem_efeito"] = True | |
| return resultado | |
| except Exception as e: | |
| return {"sucesso": False, "mensagem": str(e)} | |
| def _ponto_do_arrasto(x, y, elemento, janela, lugar=None): | |
| """(tx, ty, como) em px reais pra origem/destino de um arrasto.""" | |
| if lugar: | |
| tamanho = _tamanho_real() | |
| ancorado = mouse.ancora(lugar, *tamanho) if tamanho else None | |
| if ancorado: | |
| return ancorado[0], ancorado[1], f"'{lugar}' of the screen" | |
| elemento = elemento or lugar # não é um lugar da tela: é um elemento | |
| elemento = (elemento or "").strip() | |
| if elemento and (x is None or y is None): | |
| achado = _ponto_por_acessibilidade(elemento, janela) | |
| if achado: | |
| return achado | |
| return _mirar(x, y, elemento or None, janela) | |
| def _arrastar_real(x0, y0, x1, y1, botao): | |
| pyautogui.moveTo(x0, y0, duration=DURACAO_MOVER_MOUSE_SEG) | |
| time.sleep(PAUSA_HOVER_SEG) | |
| pyautogui.mouseDown(x=x0, y=y0, button=botao) | |
| try: | |
| time.sleep(PAUSA_SEGURAR_SEG) | |
| for i, (px, py) in enumerate(mouse.caminho(x0, y0, x1, y1)): | |
| pyautogui.moveTo(px, py) | |
| # os 3 primeiros (o "empurrão") devagar: aí o app vê um arrasto | |
| time.sleep(DURACAO_PASSO_ARRASTO_SEG * (4 if i < 3 else 1)) | |
| time.sleep(PAUSA_SOLTAR_SEG) | |
| finally: | |
| pyautogui.mouseUp(x=x1, y=y1, button=botao) | |
| def _janela_do_arrasto(a): | |
| """Nome da janela, se o que se quer arrastar é a PRÓPRIA janela ("a | |
| janela do openTARS", a barra de título, ou só o nome de um app aberto, | |
| sem ponto de origem), senão None. Arrastar uma aba ou um arquivo DENTRO | |
| da janela continua sendo arrasto de mouse.""" | |
| elemento = (a.get("from_element") or "").strip() | |
| janela = (a.get("window") or "").strip() | |
| if a.get("to_element"): | |
| return None # soltar em cima de algo: arrasto de verdade | |
| if elemento and mouse.fala_de_janela(elemento): | |
| return mouse.sem_palavras_de_janela(elemento) or janela or None | |
| if a.get("from_x") is not None and a.get("from_y") is not None: | |
| return None | |
| if elemento and set(normalizar(elemento).split()) & mouse._PALAVRAS_ABA: | |
| return None | |
| if elemento and (not janela or normalizar(janela) == normalizar(elemento)): | |
| achada = encontrar_janela_por_nome(elemento) | |
| # o nome é de um app aberto (classe/binário), não só um texto do título | |
| if achada and _pontuar_janela(achada, _termos_para_janela(elemento)) >= 2: | |
| return elemento | |
| if not elemento and janela: | |
| return janela | |
| return None | |
| def arrastar_mouse(a): | |
| """Handler de drag_mouse: segura o botão na origem, leva até o destino | |
| como uma pessoa faria e solta. Confere se a tela mudou.""" | |
| if ERRO_PYAUTOGUI: | |
| return {"sucesso": False, "mensagem": f"Cannot control the mouse: {ERRO_PYAUTOGUI}."} | |
| janela_inteira = _janela_do_arrasto(a) | |
| if janela_inteira and (a.get("to") or (a.get("to_x") is not None and a.get("to_y") is not None)): | |
| # "arraste a janela do openTARS pro topo esquerdo": mover janela é com | |
| # o gerenciador de janelas (antes o mouse ia no TEXTO "openTARS"). | |
| return mover_janela(janela_inteira, lugar=a.get("to"), x=a.get("to_x"), y=a.get("to_y")) | |
| janela = a.get("window") or None | |
| botao = a.get("button") if a.get("button") in ("left", "right", "middle") else "left" | |
| tem_origem = a.get("from_element") or (a.get("from_x") is not None and a.get("from_y") is not None) | |
| tem_destino = a.get("to") or a.get("to_element") or (a.get("to_x") is not None and a.get("to_y") is not None) | |
| if not tem_destino: | |
| return {"sucesso": False, "mensagem": ( | |
| "ERROR: say where to drag it: to='top-left' (or 'right', 'center', 'bottom-right'...), " | |
| "to_element='<what to drop it on>', or to_x/to_y (pixels of the last screenshot).")} | |
| try: | |
| if tem_origem: | |
| x0, y0, como0 = _ponto_do_arrasto(a.get("from_x"), a.get("from_y"), a.get("from_element"), janela) | |
| if x0 is None: | |
| return {"sucesso": False, "mensagem": f"Could not find what to drag: {como0}"} | |
| else: | |
| x0, y0 = pyautogui.position() | |
| como0 = "from where the mouse is" | |
| # O destino fica fora da janela de origem muitas vezes ("pro topo | |
| # esquerdo da tela"): só procura DENTRO da janela se for um elemento. | |
| x1, y1, como1 = _ponto_do_arrasto(a.get("to_x"), a.get("to_y"), a.get("to_element"), | |
| janela, lugar=a.get("to")) | |
| if x1 is None: | |
| return {"sucesso": False, "mensagem": f"Could not find where to drop it: {como1}"} | |
| if abs(x1 - x0) + abs(y1 - y0) < 3: | |
| return {"sucesso": False, "mensagem": "ERROR: the start and the end of the drag are the same point."} | |
| conferido = _conferir(lambda: _arrastar_real(x0, y0, x1, y1, botao), x1, y1) | |
| except Exception as e: | |
| return {"sucesso": False, "mensagem": f"ERROR dragging: {e}"} | |
| p0, p1 = _para_print(x0, y0), _para_print(x1, y1) | |
| detalhes = "; ".join(x for x in (f"start: {como0}" if como0 else "", f"end: {como1}" if como1 else "") if x) | |
| mensagem = (f"Dragged with the {botao} button held from ({p0[0]}, {p0[1]}) to ({p1[0]}, {p1[1]})" | |
| + (f" ({detalhes})" if detalhes else "") + ".") | |
| if conferido == "nada": | |
| # Pode ser legítimo (a aba já era a primeira), por isso não é erro; | |
| # mas quase sempre quer dizer que não pegou o item: a IA é avisada. | |
| return {"sucesso": True, "sem_efeito": True, "mensagem": mensagem + ( | |
| " WARNING: NOTHING changed on screen, so it probably did not grab the item. Unless it was " | |
| "already there, take a screenshot and call drag_mouse again with from_element='<what to " | |
| "drag>' (openTARS finds it) or corrected from_x/from_y. Don't say it worked without checking.")} | |
| return {"sucesso": True, "mensagem": "SUCCESS: " + mensagem + ( | |
| " The screen changed." if conferido in ("perto", "longe") else "")} | |
| def scroll_mouse(valor, x=None, y=None): | |
| try: | |
| if x is not None and y is not None: | |
| pyautogui.moveTo(*_para_tela(x, y), duration=DURACAO_MOVER_PARA_SCROLL_SEG) | |
| pyautogui.scroll(int(valor)) | |
| return {"sucesso": True, "mensagem": f"Scrolled {valor}."} | |
| except Exception as e: | |
| return {"sucesso": False, "mensagem": str(e)} | |
| def _definir_clipboard(texto): | |
| """Coloca texto na área de transferência (tars_plataforma). Usado pra digitar texto multilíngue: o | |
| pyautogui simula teclas físicas e não digita de forma confiável | |
| acentos, cedilha ou alfabetos fora do layout US (japonês, árabe, | |
| cirílico etc.); colar via clipboard funciona pra qualquer idioma | |
| independente do layout de teclado.""" | |
| try: | |
| plataforma.escrever_area_de_transferencia(texto) | |
| return True | |
| except OSError: | |
| return False | |
| def digitar_texto(texto, _ja_confirmado=False): | |
| if not _ja_confirmado: | |
| barrado = _barrar_texto_para_terminal(texto) | |
| if barrado: | |
| return barrado | |
| try: | |
| if texto.isascii(): | |
| # Caminho rápido de sempre — nenhuma mudança de | |
| # comportamento ou desempenho pro caso comum (ASCII). | |
| pyautogui.write(texto, interval=INTERVALO_DIGITACAO_SEG) | |
| return {"sucesso": True, "mensagem": "Text typed."} | |
| # Texto com acentos/outros idiomas: cola via clipboard em vez | |
| # de tecla-a-tecla, o que também é mais rápido pra textos | |
| # longos além de mais confiável. | |
| if _definir_clipboard(texto): | |
| pyautogui.hotkey(plataforma.tecla_comando(), "v") | |
| return {"sucesso": True, "mensagem": "Text typed (pasted through the clipboard)."} | |
| # Sem xclip/xsel disponível: melhor esforço mesmo assim. | |
| pyautogui.write(texto, interval=INTERVALO_DIGITACAO_SEG) | |
| return { | |
| "sucesso": True, | |
| "mensagem": ( | |
| "Text typed, but xclip/xsel is not installed: special characters " | |
| "may not have come out right." | |
| ), | |
| } | |
| except Exception as e: | |
| return {"sucesso": False, "mensagem": str(e)} | |
| _APELIDOS_TECLAS = { | |
| "page down": "pagedown", "page up": "pageup", "windows": "win", "super": "win", "meta": "win", | |
| "arrowdown": "down", "arrowup": "up", "arrowleft": "left", "arrowright": "right", | |
| "arrow down": "down", "arrow up": "up", "arrow left": "left", "arrow right": "right", | |
| "control": "ctrl", "del": "delete", "return": "enter", "escape": "esc", "spacebar": "space", | |
| } | |
| def _tecla_valida(tecla): | |
| """Nome de tecla que o pyautogui conhece; ValueError se não existir (o pyautogui, | |
| sozinho, ignora o nome desconhecido e a ferramenta dizia que apertou).""" | |
| t_ = _APELIDOS_TECLAS.get(str(tecla or "").strip().lower(), str(tecla or "").strip().lower()) | |
| ok = getattr(pyautogui, "isValidKey", None) | |
| if not t_ or (ok and not ok(t_)): | |
| raise ValueError(f"unknown key '{tecla}'. Examples: enter, esc, tab, up, down, pagedown, f5, ctrl, alt, shift, win.") | |
| return t_ | |
| def pressionar_tecla(tecla): | |
| try: | |
| if "+" in str(tecla) and len(str(tecla).strip()) > 1: | |
| return atalho_teclado(str(tecla)) # "ctrl+s" não é uma tecla: é um atalho | |
| pyautogui.press(_tecla_valida(tecla)) | |
| return {"sucesso": True, "mensagem": f"Pressed '{tecla}'."} | |
| except Exception as e: | |
| return {"sucesso": False, "mensagem": str(e)} | |
| def atalho_teclado(teclas): | |
| try: | |
| if isinstance(teclas, str): | |
| teclas = [x.strip() for x in teclas.split("+")] | |
| if not teclas: | |
| raise ValueError("no keys given") | |
| pyautogui.hotkey(*[_tecla_valida(k) for k in teclas]) | |
| return { | |
| "sucesso": True, | |
| "mensagem": f"Pressed {'+'.join(teclas)}.", | |
| } | |
| except Exception as e: | |
| return {"sucesso": False, "mensagem": str(e)} | |