"""
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]