JustPaste.it
"""
Motor central de adição de membros
-------------------------------------------
• Todas as contas entram no grupo ANTES de iniciar
• Rotação entre contas ativas
• Delay humano e pausas longas
• Limite diário por conta
• Tratamento anti‑spam (PeerFlood)
• Persistência total em JSON (usuarios + estado_das_contas)
• Logs detalhados

[ATUALIZAÇÃO]
1) Pausa Global a cada X adições no GRUPO (estado_global.json).
2) Removida a lógica de "pausa individual por múltiplos", que era redundante.
3) Quando não houver nenhuma conta apta por causa da PAUSA GLOBAL, o motor entra em HIBERNÁÇÃO (sleep) pelo tempo restante, retomando as atividades automaticamente.
4) A pausa global é resetada automaticamente assim que expira, evitando pausas acumulativas.
5) O restante do código permanece exatamente como antes.

[NOVO – Tolerância de Spam]
6) Adicionada variável config.TOLERANCIA_SPAM_POR_CONTA.
   • Cada conta pode receber N incidentes de spam antes de ser bloqueada.
   • Contador vive apenas em RAM e é zerado quando a conta bloqueia ou o
     programa reinicia.

[NOVO – Rotação de Verificação]
7) Sistema independente de rotação de contas para verificação de membros:
   • Fila separada apenas para checagem de contagem de membros
   • Rotação evita sobrecarga de requests na mesma conta
   • Não afeta a lógica principal de adição
"""

import asyncio
import json
import os
import random
import time
from datetime import datetime, timezone, date
from typing import Dict, List, Tuple

from telethon import errors, functions

import config
from colorama import Fore, Style, init

init(autoreset=True)

# ---------- utilidades internas ---------- #
def _utc_ts() -> int:
    return int(time.time())

def _hoje_str() -> str:
    return date.today().isoformat()

def _ler_json(caminho: str, default):
    if os.path.isfile(caminho):
        with open(caminho, "r", encoding="utf-8") as f:
            return json.load(f)
    return default

def _salvar_json(caminho: str, data):
    """
    Abre no modo 'w' (sobrescrita),
    mas não removemos nada fora do escopo.
    """
    with open(caminho, "w", encoding="utf-8") as f:
        json.dump(data, f, indent=2, ensure_ascii=False)

def _parse_link(link: str) -> Tuple[str, str]:
    """
    Retorna ("publico", username) ou ("convite", hash)
    Exemplo:
        https://t.me/MeuGrupo
        https://t.me/+zUBtzvW2iyZkNTBh
    """
    link = link.strip()
    if "+" in link:
        return "convite", link.split("+")[1]
    return "publico", link.rstrip("/").rsplit("/", 1)[-1]

# ========== ARQUIVO PARA ESTADO GLOBAL (PAUSA DE GRUPO) ===========
ESTADO_GLOBAL_PATH = "estado_global.json"

# ---------- classe principal ---------- #
class MotorDeAdicao:
    def __init__(self, gerenciador):
        self.gerenciador = gerenciador
        self.usuarios_path = config.USUARIOS_JSON
        self.estado_path = config.ESTADO_CONTAS_JSON

        self.usuarios: Dict[str, List[dict]] = _ler_json(self.usuarios_path, {})
        self.estado_contas: Dict[str, dict] = _ler_json(self.estado_path, {})

        # === NOVO: contador de tentativas de spam em memória ===
        self._spam_tentativas: Dict[str, int] = {}

        # === FILA DE VERIFICAÇÃO INDEPENDENTE ===
        self._fila_verificacao = list(gerenciador.clients.keys())
        random.shuffle(self._fila_verificacao)
        self._idx_verificacao = 0

        # -----------------------------------------------------------------
        # [CORREÇÃO STATUS BLOQUEIO EXPIRADO]  (executada logo no __init__)
        # -----------------------------------------------------------------
        agora_ts = _utc_ts()
        mudou_status = False
        for phone, est in self.estado_contas.items():
            if est.get("status") == "bloqueada" and est.get("bloqueada_ate", 0) <= agora_ts:
                est["status"] = "ativa"
                est["bloqueada_ate"] = 0
                mudou_status = True
        if mudou_status:
            _salvar_json(self.estado_path, self.estado_contas)
        # -----------------------------------------------------------------

        # Garante que cada phone do gerenciador tenha registro em estado_contas
        for phone in self.gerenciador.clients.keys():
            self.estado_contas.setdefault(
                phone,
                {
                    "status": "ativa",
                    "bloqueada_ate": 0,
                    "em_pausa_ate": 0,
                    "adicionados_hoje": 0,
                    "ultima_adicao_timestamp": 0,
                    "data_referencia": _hoje_str(),
                },
            )
            # NOVO: Inicializa a chave "grupos_entrados" se não existir para persistência de entrada no grupo
            if "grupos_entrados" not in self.estado_contas[phone]:
                self.estado_contas[phone]["grupos_entrados"] = []
            # NOVO: contador de spam começa zerado
            self._spam_tentativas[phone] = 0
        _salvar_json(self.estado_path, self.estado_contas)

        # Pré‑parse do link do grupo
        self._tipo_link, self._valor_link = _parse_link(config.GRUPO_LINK)

        # =========== CARREGAR/CRIAR ESTADO GLOBAL (PAUSA DO GRUPO) ===========
        self.estado_global = _ler_json(ESTADO_GLOBAL_PATH, {})
        if "data_referencia" not in self.estado_global:
            self.estado_global["data_referencia"] = _hoje_str()
        if "total_adicionados_hoje" not in self.estado_global:
            self.estado_global["total_adicionados_hoje"] = 0
        if "em_pausa_ate" not in self.estado_global:
            self.estado_global["em_pausa_ate"] = 0
        _salvar_json(ESTADO_GLOBAL_PATH, self.estado_global)

    def _obter_conta_verificacao(self):
        """Obtém próxima conta para verificação de membros de forma rotativa"""
        if self._idx_verificacao >= len(self._fila_verificacao):
            self._idx_verificacao = 0
        phone = self._fila_verificacao[self._idx_verificacao]
        self._idx_verificacao += 1
        return self.gerenciador.clients[phone]

    # ------------- API pública ------------- #
    async def executar(self):
        print("=== MOTOR DE ADIÇÃO INICIADO ===")

        # 1) Garantir que TODAS as contas estejam no grupo antes de iniciar adições
        await self._verificar_todas_as_contas_no_grupo()

        # 2) Loop principal de adição
        contas = list(self.gerenciador.clients.items())
        if not contas:
            print("Nenhuma conta ativa disponível.")
            return

        while self._ha_usuarios_pendentes():
            # Filtra apenas as contas aptas
            contas_liberadas = [
                (phone, client)
                for (phone, client) in contas
                if self._conta_pode_operar(phone)
            ]
            if not contas_liberadas:
                # VERIFICAR SE É POR CAUSA DE PAUSA GLOBAL
                agora = _utc_ts()
                if self.estado_global.get("em_pausa_ate", 0) > agora:
                    duracao = self.estado_global["em_pausa_ate"] - agora
                    print(f"\n⏳ Todas as contas estão em PAUSA GLOBAL. Hibernando por {duracao} segundos...\n")
                    await asyncio.sleep(duracao)
                    continue  # Após dormir, retoma o loop principal.
               
                # Caso contrário, verifica se todas as contas estão bloqueadas ou atingiram o limite diário
                total = len(contas)
                bloqueadas = [
                    phone for (phone, _) in contas
                    if self.estado_contas[phone].get("status") == "bloqueada"
                ]
                limite_batido = [
                    phone for (phone, _) in contas
                    if self.estado_contas[phone].get("adicionados_hoje", 0) >= config.LIMITE_DIARIO_POR_CONTA
                ]
                if len(bloqueadas) == total:
                    print("\n❌ TODAS as contas estão bloqueadas por SPAM. Encerrando.")
                    return
                if len(limite_batido) == total:
                    print("\n✅ TODAS as contas atingiram o limite diário de adições. Encerrando.")
                    return

                # Se não for por pausa global mas nenhuma conta estiver apta
                print("\n⚠️ Nenhuma conta está apta no momento. Encerrando.")
                return

            for phone, client in contas_liberadas:
                print(f"\n[USANDO CONTA] {phone} - Verificando se pode operar.")
                if not self._conta_pode_operar(phone):
                    print(f"[STATUS] Conta {phone} não pode operar no momento (pausa ou bloqueio).")
                    continue

                usuario = self._proximo_usuario_pendente(phone)
                if not usuario:
                    print(f"[STATUS] Conta {phone} não possui usuários pendentes para adicionar.")
                    continue

                print(f"[INFO] Conta {phone} vai tentar adicionar @{usuario['username']}.")

                # Tenta adicionar e obtém resultado
                resultado = await self._tentar_adicionar(client, usuario)

                # Trata esse resultado
                self._tratar_resultado(phone, usuario, resultado)

                # Persiste alterações
                self._persistir_tudo()

                # Chamada à função de pausa individual (mantida vazia)
                self._aplicar_regras_de_pausa(phone)

                # Delay aleatório
                delay = random.randint(*config.DELAY_ENTRE_ADICOES_SEGUNDOS)
                print(f"[DELAY] Conta {phone} aguardará {delay}s até a próxima tentativa.")
                await asyncio.sleep(delay)

            # Pequena pausa entre contas
            await asyncio.sleep(1)

        print("=== NÃO RESTAM USUÁRIOS PENDENTES ===")

    # ------------- verificação de grupo ------------- #
    async def _verificar_todas_as_contas_no_grupo(self):
        print("== VERIFICANDO ENTRADA DE TODAS AS CONTAS NO GRUPO ==")
        for phone, client in self.gerenciador.clients.items():
            # NOVO: Verifica se a conta já possui registro de entrada no grupo no estado
            estado = self.estado_contas.get(phone, {})
            if "grupos_entrados" not in estado:
                estado["grupos_entrados"] = []
            if config.GRUPO_LINK in estado["grupos_entrados"]:
                print(f"{Fore.YELLOW}[JOIN PULADO] Conta {phone} já registrada como presente no grupo.{Style.RESET_ALL}")
                continue

            try:
                if self._tipo_link == "convite":
                    await client(
                        functions.messages.ImportChatInviteRequest(hash=self._valor_link)
                    )
                else:
                    await client(
                        functions.channels.JoinChannelRequest(channel=self._valor_link)
                    )
                print(f"{Fore.GREEN}[JOIN OK] Conta {phone} entrou no grupo.{Style.RESET_ALL}")
                # Registra a entrada no grupo para a conta
                estado["grupos_entrados"].append(config.GRUPO_LINK)
                _salvar_json(self.estado_path, self.estado_contas)
            except errors.UserAlreadyParticipantError:
                print(f"{Fore.YELLOW}[JOIN OK] Conta {phone} já estava no grupo.{Style.RESET_ALL}")
                if config.GRUPO_LINK not in estado["grupos_entrados"]:
                    estado["grupos_entrados"].append(config.GRUPO_LINK)
                    _salvar_json(self.estado_path, self.estado_contas)
            except Exception as e:
                print(f"{Fore.RED}[JOIN FALHOU] Conta {phone} não entrou no grupo: {e}{Style.RESET_ALL}")
                raise RuntimeError(f"Abortando: conta {phone} não conseguiu entrar no grupo.")

    # ------------- lógica interna ------------- #
    def _ha_usuarios_pendentes(self) -> bool:
        """Verifica se ainda há algum usuário com status 'pendente' em qualquer conta."""
        return any(
            u["status"] == "pendente"
            for lista in self.usuarios.values()
            for u in lista
        )

    def _conta_pode_operar(self, phone: str) -> bool:
        """
        Verifica se a conta está dentro dos limites/pausas individuais
        e se não há pausa global ativa.
        """
        agora = _utc_ts()

        # 1) Reset diário global se mudou o dia
        if self.estado_global["data_referencia"] != _hoje_str():
            self.estado_global["data_referencia"] = _hoje_str()
            self.estado_global["total_adicionados_hoje"] = 0
            self.estado_global["em_pausa_ate"] = 0
            _salvar_json(ESTADO_GLOBAL_PATH, self.estado_global)

        # 2) Pausa Global: se a pausa ainda está ativa, retorna False.
        if self.estado_global.get("em_pausa_ate", 0) > agora:
            global_pause_legivel = datetime.fromtimestamp(self.estado_global["em_pausa_ate"]).strftime('%Y-%m-%d %H:%M:%S')
            print(f"{Fore.RED}[STATUS GLOBAL] Pausa Global até {global_pause_legivel}.{Style.RESET_ALL}")
            return False
        else:
            # Reseta a pausa global expirado
            if self.estado_global.get("em_pausa_ate", 0) != 0:
                self.estado_global["em_pausa_ate"] = 0
                _salvar_json(ESTADO_GLOBAL_PATH, self.estado_global)

        # 3) Lógica individual já existente
        est = self.estado_contas[phone]

        # Reset diário individual
        if est["data_referencia"] != _hoje_str():
            est["data_referencia"] = _hoje_str()
            est["adicionados_hoje"] = 0
            est["em_pausa_ate"] = 0

        # Bloqueio por spam
        if est["bloqueada_ate"] > agora:
            est["status"] = "bloqueada"
            bloqueio_legivel = datetime.fromtimestamp(est["bloqueada_ate"]).strftime('%Y-%m-%d %H:%M:%S')
            print(f"{Fore.RED}[STATUS] Conta {phone} está bloqueada até {bloqueio_legivel}.{Style.RESET_ALL}")
            return False
        else:
            # -----------------------------------------------------------------
            # [CORREÇÃO STATUS BLOQUEIO EXPIRADO]  (ocorreu agora)
            # -----------------------------------------------------------------
            if est["bloqueada_ate"] != 0 and est["bloqueada_ate"] <= agora:
                self._spam_tentativas[phone] = 0
                est["bloqueada_ate"] = 0
                est["status"] = "ativa"
                _salvar_json(self.estado_path, self.estado_contas)
            # -----------------------------------------------------------------

        # Pausa individual (caso esteja em vigor)
        if est["em_pausa_ate"] > agora:
            est["status"] = "em_pausa"
            pausa_legivel = datetime.fromtimestamp(est["em_pausa_ate"]).strftime('%Y-%m-%d %H:%M:%S')
            print(f"{Fore.YELLOW}[STATUS] Conta {phone} está em pausa até {pausa_legivel}.{Style.RESET_ALL}")
            return False

        # Limite diário individual
        if est["adicionados_hoje"] >= config.LIMITE_DIARIO_POR_CONTA:
            est["status"] = "em_pausa"
            meia_noite = (
                datetime.now(timezone.utc)
                .replace(hour=0, minute=0, second=0, microsecond=0)
                .timestamp()
                + 86400
            )
            est["em_pausa_ate"] = int(meia_noite)
            print(f"{Fore.YELLOW}[PAUSA DIÁRIA] Conta {phone} atingiu o limite diário e ficará pausada até {datetime.fromtimestamp(est['em_pausa_ate']).strftime('%Y-%m-%d %H:%M:%S')}.{Style.RESET_ALL}")
            _salvar_json(self.estado_path, self.estado_contas)
            return False

        # Tudo OK: garante que o status reflita 'ativa'
        if est.get("status") != "ativa":
            est["status"] = "ativa"
            _salvar_json(self.estado_path, self.estado_contas)

        return True

    def _proximo_usuario_pendente(self, phone: str):
        """Retorna o próximo usuário pendente dessa conta."""
        return next(
            (u for u in self.usuarios.get(phone, []) if u["status"] == "pendente"),
            None
        )

    # ---------------------------------------------------------------------
    # NOVO MÉTODO → obtém contagem total de membros do grupo
    # ---------------------------------------------------------------------
    async def _obter_total_membros(self, client) -> int:
        """
        Retorna o número total de membros do grupo.
        Se falhar, devolve -1.
        """
        try:
            if self._tipo_link == "convite":
                # Para links de convite, precisaremos resolver a entidade primeiro
                convite_info = await client(functions.messages.CheckChatInviteRequest(self._valor_link))
                entidade = await client.get_entity(convite_info.chat)
            else:
                entidade = await client.get_entity(self._valor_link)

            full = await client(functions.channels.GetFullChannelRequest(entidade))
            return getattr(full.full_chat, "participants_count", -1)
        except Exception as e:
            print(f"[ERRO] Falha ao obter número de membros: {e}")
            return -1

    # ---------------------------------------------------------------------
    # MÉTODO ALTERADO COMPLETAMENTE → verifica se a adição refletiu no grupo
    # ---------------------------------------------------------------------
    async def _tentar_adicionar(self, client, usuario: dict) -> str:
        """
        Tenta adicionar o usuario no grupo e valida pelo total de membros.
        Retorna status: "sucesso", "privacidade::SilentFail", "spam::Tipo",
                        "sem_username", "erro::Tipo::msg"
        """
        username = usuario["username"]
        if not username:
            return "sem_username"

        try:
            # -------- [1] OBTÉM CLIENTE DE VERIFICAÇÃO (ROTAÇÃO SEPARADA) --------
            verificador = self._obter_conta_verificacao()
           
            # -------- [2] CONTAGEM ANTES (USANDO CONTA DE VERIFICAÇÃO) --------
            membros_antes = await self._obter_total_membros(verificador)
            if membros_antes == -1:
                print("[WARN] Não foi possível ler membros_antes — continuando sem validação extra.")
           
            # -------- [3] TENTATIVA DE CONVITE (CONTA NORMAL) --------
            input_user = await client.get_input_entity(username)
            channel_entity = await client.get_input_entity(config.GRUPO_LINK)

            await client(functions.channels.InviteToChannelRequest(
                channel=channel_entity, users=[input_user]
            ))

            # -------- [4] ESPERA E VERIFICAÇÃO COM CONTA DE VERIFICAÇÃO --------
            await asyncio.sleep(8)
            membros_depois = await self._obter_total_membros(verificador)

            # -------- [5] VALIDAÇÃO FINAL --------
            if membros_antes != -1 and membros_depois != -1:
                if membros_depois == membros_antes + 1:
                    return "sucesso"
                else:
                    # Falha silenciosa (privacidade) — API disse ok, mas contagem não mudou
                    return "privacidade::SilentFail"
            else:
                # Não conseguimos validar a contagem, então confiamos na resposta da API
                return "sucesso"

        except errors.UserPrivacyRestrictedError as e:
            return f"privacidade::{type(e).__name__}"
        except errors.PeerFloodError as e:
            return f"spam::{type(e).__name__}"
        # ---------------------- NOVO TRATAMENTO FLOODWAIT ----------------------
        except errors.FloodWaitError as e:
            return f"spam::{type(e).__name__}"
        # ----------------------------------------------------------------------
        except errors.UserAlreadyParticipantError:
            return "sucesso"
        except (errors.UsernameInvalidError, errors.UsernameNotOccupiedError) as e:
            return f"privacidade::{type(e).__name__}"
        except Exception as e:
            print(f"{Fore.MAGENTA}[ERRO] Falha ao adicionar @{username}: {e}{Style.RESET_ALL}")
            return f"erro::{type(e).__name__}::{str(e)}"

    def _tratar_resultado(self, phone: str, usuario: dict, resultado: str):
        """
        Atualiza status do usuario, logs, e contadores.
        Implementa a Pausa GLOBAL a cada X adições no grupo (estado_global.json).
        """
        est = self.estado_contas[phone]
        agora = _utc_ts()
        uname = usuario["username"]

        partes = resultado.split("::", maxsplit=2)
        status_principal = partes[0]
        ex_name = partes[1] if len(partes) > 1 else None
        ex_msg = partes[2] if len(partes) > 2 else None

        # Registro mínimo
        usuario["conta_responsavel"] = phone
        usuario["tentativa_em"] = datetime.now().isoformat()

        if ex_name:
            usuario["motivo"] = ex_name
        if ex_msg:
            usuario["erro_msg"] = ex_msg

        if status_principal == "sucesso":
            usuario["status"] = "adicionado"
            est["adicionados_hoje"] += 1
            est["ultima_adicao_timestamp"] = agora
            print(f"{Fore.GREEN}✅ [{phone}] ADICIONOU → @{uname} (ID: {usuario.get('user_id')}){Style.RESET_ALL}")

            # Incrementa contador GLOBAL
            self.estado_global["total_adicionados_hoje"] += 1
            _salvar_json(ESTADO_GLOBAL_PATH, self.estado_global)

            # Se atingiu múltiplo global, inicia PAUSA GLOBAL
            if (
                self.estado_global["total_adicionados_hoje"] > 0
                and self.estado_global["total_adicionados_hoje"] % config.PAUSA_A_CADA_X_MEMBROS == 0
            ):
                nova_pausa_ate = _utc_ts() + config.TEMPO_DA_PAUSA_EM_SEGUNDOS
                self.estado_global["em_pausa_ate"] = nova_pausa_ate

                # Pausa todas as contas: atualiza o 'em_pausa_ate' individualmente
                for p in self.estado_contas.keys():
                    self.estado_contas[p]["em_pausa_ate"] = max(
                        self.estado_contas[p]["em_pausa_ate"],
                        nova_pausa_ate
                    )
                _salvar_json(ESTADO_GLOBAL_PATH, self.estado_global)
                self._persistir_tudo()

                print(f"{Fore.YELLOW}[PAUSA GLOBAL] Iniciada até {nova_pausa_ate} após {self.estado_global['total_adicionados_hoje']} adições no grupo.{Style.RESET_ALL}")

        elif status_principal == "privacidade":
            usuario["status"] = "inadiavel"
            print(f"{Fore.YELLOW}[PRIVACIDADE] @{uname} não permite convite (conta {phone}). Motivo: {ex_name}{Style.RESET_ALL}")

        elif status_principal == "spam":
            usuario["status"] = "erro_temporario"

            # ============ NOVO BLOQUEIO POR TOLERÂNCIA ============
            self._spam_tentativas[phone] += 1
            limite = getattr(config, "TOLERANCIA_SPAM_POR_CONTA", 1)
            faltam = limite - self._spam_tentativas[phone]

            if self._spam_tentativas[phone] >= limite:
                est["status"] = "bloqueada"
                est["bloqueada_ate"] = agora + config.TEMPO_BLOQUEIO_SPAM_SEGUNDOS
                est["em_pausa_ate"] = est["bloqueada_ate"]
                print(f"{Fore.RED}[SPAM] Conta {phone} BLOQUEADA até {est['bloqueada_ate']} ({ex_name}).{Style.RESET_ALL}")
                # Zera para próximo ciclo após desbloqueio
                self._spam_tentativas[phone] = 0
            else:
                print(f"{Fore.MAGENTA}[SPAM] Conta {phone} incidente ({ex_name}). Restam {faltam} antes do bloqueio.{Style.RESET_ALL}")
            # ======================================================

        elif status_principal == "sem_username":
            usuario["status"] = "inadiavel"
            print(f"{Fore.CYAN}[SEM USERNAME] @{uname} não é utilizável pela conta {phone}.{Style.RESET_ALL}")

        else:
            usuario["status"] = "erro"
            print(f"{Fore.MAGENTA}[ERRO] @{uname} marcado como erro genérico na conta {phone}. Motivo: {ex_name}{Style.RESET_ALL}")

    def _aplicar_regras_de_pausa(self, phone: str):
        """
        [REMOVIDO] A lógica antiga de pausa a cada X adições individuais
        não é mais necessária, pois já existe LIMITE_DIARIO_POR_CONTA
        e a PAUSA GLOBAL no GRUPO.
        Mantemos este método vazio para não quebrar nenhuma chamada atual.
        """
        pass

    def _persistir_tudo(self):
        """
        Salva alterações de usuarios e estado das contas em disco.
        """
        _salvar_json(self.usuarios_path, self.usuarios)
        _salvar_json(self.estado_path, self.estado_contas)

# [FIM DO ARQUIVO]