diff --git a/.dockerignore b/.dockerignore index d0d67da..f53fbf4 100644 --- a/.dockerignore +++ b/.dockerignore @@ -2,4 +2,12 @@ docker-composer.yaml docker-compose.yaml .gitignore -.git \ No newline at end of file +.git +__pycache__ +*.pyc +.git +.env +.venv +*.md +*.postman_collection.json +deploy_*.sh \ No newline at end of file diff --git a/.env.example b/.env.example index 1ad59e6..0ccdcd6 100644 --- a/.env.example +++ b/.env.example @@ -1,14 +1,12 @@ -# Chave da API para transcrição (Groq ou qualquer outro serviço que você utilizar) -GROQ_API_KEY=substitua_sua_chave_GROQ_aqui +# Debug e Logs +DEBUG_MODE=false +LOG_LEVEL=INFO -# Comportamento da transcrição -PROCESS_SELF_MESSAGES=true -BUSINESS_MESSAGE="substitua_sua_mensagem_de_servico_aqui" -PROCESS_GROUP_MESSAGES=false +# Credenciais do Gerenciador +MANAGER_USER=admin +MANAGER_PASSWORD=impacteai2024 -# Host e porta do Redis (caso esteja utilizando) -REDIS_HOST=localhost -REDIS_PORT=6379 - -DEBUG_MODE=true -LOG_LEVEL=INFO \ No newline at end of file +# Configurações do Servidor +FASTAPI_PORT=8005 +STREAMLIT_PORT=8501 +HOST=0.0.0.0 diff --git a/.gitignore b/.gitignore index ab01a68..5309f25 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,7 @@ *.pyc docker-composer.yaml GPT.postman_collection.json -.venv/ \ No newline at end of file +.venv/ +.gitignore +deploy_producao.sh +Dockerfile \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index f73c40b..1166dcd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,19 +1,31 @@ # Usar uma imagem oficial do Python como base FROM python:3.10-slim +# Instalar dependências do sistema +RUN apt-get update && apt-get install -y --no-install-recommends \ + && apt-get clean && rm -rf /var/lib/apt/lists/* + # Definir o diretório de trabalho WORKDIR /app -# Copiar o requirements.txt e instalar dependências +# Copiar o arquivo requirements.txt e instalar dependências COPY requirements.txt . - RUN pip install --no-cache-dir -r requirements.txt -# Copiar todo o código para dentro do contêiner +# Copiar todo o código da aplicação COPY . . -# Expor a porta onde o FastAPI vai rodar -EXPOSE 8005 +# Garantir que o diretório static existe +RUN mkdir -p /app/static -# Comando para iniciar a aplicação -CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8005"] +# Copiar arquivos estáticos para o diretório apropriado +COPY static/ /app/static/ + +# Garantir permissões de execução ao script inicial +RUN chmod +x start.sh + +# Expor as portas usadas pela aplicação +EXPOSE 8005 8501 + +# Definir o comando inicial +CMD ["./start.sh"] \ No newline at end of file diff --git a/config.py b/config.py index cdb589b..3f5b3eb 100644 --- a/config.py +++ b/config.py @@ -1,102 +1,111 @@ -import os -from dotenv import load_dotenv import logging -from pathlib import Path +import redis +import os # Configuração de logging com cores e formatação melhorada class ColoredFormatter(logging.Formatter): - """Formatter personalizado que adiciona cores aos logs""" - grey = "\x1b[38;21m" - blue = "\x1b[38;5;39m" - yellow = "\x1b[38;5;226m" - red = "\x1b[38;5;196m" - bold_red = "\x1b[31;1m" - reset = "\x1b[0m" - - def __init__(self, fmt): - super().__init__() - self.fmt = fmt - self.FORMATS = { - logging.DEBUG: self.blue + self.fmt + self.reset, - logging.INFO: self.grey + self.fmt + self.reset, - logging.WARNING: self.yellow + self.fmt + self.reset, - logging.ERROR: self.red + self.fmt + self.reset, - logging.CRITICAL: self.bold_red + self.fmt + self.reset - } + """Formatter personalizado que adiciona cores aos logs.""" + COLORS = { + logging.DEBUG: "\x1b[38;5;39m", # Azul + logging.INFO: "\x1b[38;21m", # Cinza + logging.WARNING: "\x1b[38;5;226m", # Amarelo + logging.ERROR: "\x1b[38;5;196m", # Vermelho + logging.CRITICAL: "\x1b[31;1m", # Vermelho forte + } + RESET = "\x1b[0m" def format(self, record): - log_fmt = self.FORMATS.get(record.levelno) + color = self.COLORS.get(record.levelno, self.RESET) + log_fmt = f"{color}%(asctime)s - %(name)s - %(levelname)s - %(message)s{self.RESET}" formatter = logging.Formatter(log_fmt) return formatter.format(record) # Configuração inicial do logging -logger = logging.getLogger(__name__) +logger = logging.getLogger("TranscreveZAP") handler = logging.StreamHandler() -handler.setFormatter(ColoredFormatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')) +handler.setFormatter(ColoredFormatter()) logger.addHandler(handler) -# Carregar variáveis de ambiente -env_path = Path('.env') -if env_path.exists(): - logger.debug(f"Arquivo .env encontrado em: {env_path.absolute()}") - load_dotenv(override=True) -else: - logger.warning("Arquivo .env não encontrado! Usando variáveis de ambiente do sistema.") +# Nível de log inicial (pode ser ajustado após o carregamento de configurações) +logger.setLevel(logging.INFO) + +# Conexão com o Redis +redis_client = redis.Redis( + host=os.getenv('REDIS_HOST', 'localhost'), + port=int(os.getenv('REDIS_PORT', 6380)), + db=0, + decode_responses=True +) class Settings: + """Classe para gerenciar configurações do sistema.""" def __init__(self): - logger.debug("Iniciando carregamento das configurações...") - - # Carregamento das variáveis com logs detalhados - self.DEBUG_MODE = os.getenv('DEBUG_MODE', 'false').lower() == 'true' - logger.debug(f"DEBUG_MODE configurado como: {self.DEBUG_MODE}") - - self.GROQ_API_KEY = os.getenv('GROQ_API_KEY') + """Inicializa as configurações.""" + logger.debug("Carregando configurações do Redis...") + + self.GROQ_API_KEY = self.get_redis_value("GROQ_API_KEY", "gsk_default_key") + self.BUSINESS_MESSAGE = self.get_redis_value("BUSINESS_MESSAGE", "*Impacte AI* Premium Services") + self.PROCESS_GROUP_MESSAGES = self.get_redis_value("PROCESS_GROUP_MESSAGES", "false").lower() == "true" + self.PROCESS_SELF_MESSAGES = self.get_redis_value("PROCESS_SELF_MESSAGES", "true").lower() == "true" + self.LOG_LEVEL = self.get_redis_value("LOG_LEVEL", "INFO").upper() + + # Mascarar chave ao logar if self.GROQ_API_KEY: masked_key = f"{self.GROQ_API_KEY[:10]}...{self.GROQ_API_KEY[-4:]}" logger.debug(f"GROQ_API_KEY carregada: {masked_key}") else: logger.error("GROQ_API_KEY não encontrada!") - - self.BUSINESS_MESSAGE = os.getenv('BUSINESS_MESSAGE', '*Impacte AI* Premium Services') - logger.debug(f"BUSINESS_MESSAGE configurada como: {self.BUSINESS_MESSAGE}") - - self.PROCESS_GROUP_MESSAGES = os.getenv('PROCESS_GROUP_MESSAGES', 'false').lower() == 'true' - logger.debug(f"PROCESS_GROUP_MESSAGES configurado como: {self.PROCESS_GROUP_MESSAGES}") - - self.PROCESS_SELF_MESSAGES = os.getenv('PROCESS_SELF_MESSAGES', 'false').lower() == 'true' - logger.debug(f"PROCESS_SELF_MESSAGES configurado como: {self.PROCESS_SELF_MESSAGES}") - self.LOG_LEVEL = os.getenv('LOG_LEVEL', 'INFO') - logger.debug(f"LOG_LEVEL configurado como: {self.LOG_LEVEL}") + logger.debug( + f"Configurações carregadas: LOG_LEVEL={self.LOG_LEVEL}, " + f"PROCESS_GROUP_MESSAGES={self.PROCESS_GROUP_MESSAGES}, " + f"PROCESS_SELF_MESSAGES={self.PROCESS_SELF_MESSAGES}" + ) + + def get_redis_value(self, key, default): + """Obtém um valor do Redis com fallback para o valor padrão.""" + value = redis_client.get(key) + if value is None: + logger.warning(f"Configuração '{key}' não encontrada no Redis. Usando padrão: {default}") + return default + return value + + def set_redis_value(self, key, value): + """Define um valor no Redis.""" + redis_client.set(key, value) + logger.debug(f"Configuração '{key}' atualizada no Redis") def validate(self): - """Validação detalhada das configurações críticas""" - logger.debug("Iniciando validação das configurações...") - - validation_errors = [] - - if not self.GROQ_API_KEY: - validation_errors.append("GROQ_API_KEY não está definida") - elif not self.GROQ_API_KEY.startswith('gsk_'): - validation_errors.append("GROQ_API_KEY inválida: deve começar com 'gsk_'") + """Validação detalhada das configurações críticas.""" + logger.debug("Validando configurações...") + errors = [] - if validation_errors: - for error in validation_errors: - logger.error(f"Erro de validação: {error}") + if not self.GROQ_API_KEY: + errors.append("GROQ_API_KEY não está definida.") + elif not self.GROQ_API_KEY.startswith("gsk_"): + errors.append("GROQ_API_KEY inválida: deve começar com 'gsk_'.") + + if errors: + for error in errors: + logger.error(error) return False - + logger.info("Todas as configurações foram validadas com sucesso!") return True -# Criar instância das configurações +# Instância única de configurações settings = Settings() - -# Validar configurações if not settings.validate(): - logger.critical("Configurações inválidas detectadas. A aplicação pode não funcionar corretamente!") + logger.critical("Configurações inválidas detectadas durante a inicialização.") + settings = None # Evita que seja referenciado como 'NoneType' -# Ajustar nível de log -log_level = logging.DEBUG if settings.DEBUG_MODE else getattr(logging, settings.LOG_LEVEL.upper()) -logger.setLevel(log_level) -logger.info(f"Nível de log definido como: {logging.getLevelName(log_level)}") \ No newline at end of file +def load_settings(): + """ + Recarrega as configurações do Redis. + """ + global settings + settings = Settings() + # Ajustar nível de log + log_level = getattr(logging, settings.LOG_LEVEL, logging.INFO) + logger.setLevel(log_level) + logger.info(f"Nível de log ajustado para: {logging.getLevelName(log_level)}") \ No newline at end of file diff --git a/data/config.json b/data/config.json new file mode 100644 index 0000000..1db4415 --- /dev/null +++ b/data/config.json @@ -0,0 +1,7 @@ +{ + "GROQ_API_KEY": "default_key", + "BUSINESS_MESSAGE": "*Impacte AI* Premium Services", + "PROCESS_GROUP_MESSAGES": false, + "PROCESS_SELF_MESSAGES": true, + "DEBUG_MODE": false +} diff --git a/docker-compose.yaml b/docker-compose.yaml index d89d8f4..1a0c12c 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,24 +1,32 @@ version: "3.7" services: - transcricaoaudio: + tcaudio: image: impacteai/transcrevezap:latest build: . networks: - - suarededocker #troque pela sua rede do docker + - suarededocker ports: - - 8005:8005 + - 8005:8005 # FastAPI + - 8501:8501 # Streamlit environment: Uvicorn_port: 8005 Uvicorn_host: 0.0.0.0 Uvicorn_reload: "true" Uvicorn_workers: 1 - GROQ_API_KEY: "substitua_sua_chave_GROQ_aqui" #coloque sua chave GROQ aqui - BUSINESS_MESSAGE: "substitua_sua_mensagem_de_servico_aqui" #coloque a mensagem que será enviada ao final da transcrição aqui - PROCESS_GROUP_MESSAGES: "false" # Define se mensagens de grupos devem ser processadas - PROCESS_SELF_MESSAGES: "true" # Define se sua próprias mensagens devem ser processadas + GROQ_API_KEY: "${GROQ_API_KEY}" + BUSINESS_MESSAGE: "*Impacte AI* Premium Services" + PROCESS_GROUP_MESSAGES: "false" + PROCESS_SELF_MESSAGES: "true" DEBUG_MODE: "false" LOG_LEVEL: "INFO" + MANAGER_USER: "admin" + MANAGER_PASSWORD: "impacte2024" + volumes: + - ./config.json:/app/config.json + - ./backups:/app/backups + - ./transcription_logs.json:/app/transcription_logs.json + - ./static:/app/static deploy: mode: replicated replicas: 1 @@ -27,20 +35,22 @@ services: - node.role == manager labels: - traefik.enable=true - - traefik.http.routers.transcricaoaudio.rule=Host(`transcricaoaudio.seudominio.com.br`) #coloque seu subdominio apontado aqui - - traefik.http.routers.transcricaoaudio.entrypoints=websecure - - traefik.http.routers.transcricaoaudio.tls.certresolver=letsencryptresolver - - traefik.http.services.transcricaoaudio.loadbalancer.server.port=8005 - - traefik.http.services.transcricaoaudio.loadbalancer.passHostHeader=true - - traefik.http.routers.transcricaoaudio.service=transcricaoaudio + - traefik.http.routers.tcaudio.rule=Host(`transcrevezap.seudominio.com.br`) + - traefik.http.routers.tcaudio.entrypoints=websecure + - traefik.http.routers.tcaudio.tls.certresolver=letsencryptresolver + - traefik.http.services.tcaudio.loadbalancer.server.port=8005 + - traefik.http.services.tcaudio.loadbalancer.passHostHeader=true + - traefik.http.routers.tcaudio.service=tcaudio - traefik.http.middlewares.traefik-compress.compress=true - - traefik.http.routers.transcricaoaudio.middlewares=traefik-compress - resources: - limits: - cpus: "1" - memory: 1024M + - traefik.http.routers.tcaudio.middlewares=traefik-compress + # Configuração do Streamlit + - traefik.http.routers.tcaudio-manager.rule=Host(`manager.transcrevezap.seudominio.com.br`) + - traefik.http.routers.tcaudio-manager.entrypoints=websecure + - traefik.http.routers.tcaudio-manager.tls.certresolver=letsencryptresolver + - traefik.http.services.tcaudio-manager.loadbalancer.server.port=8501 + - traefik.http.routers.tcaudio-manager.service=tcaudio-manager networks: - suarededocker: #troque pela sua rede do docker + suarededocker: external: true - name: suarededocker #troque pela sua rede do docker \ No newline at end of file + name: suarededocker diff --git a/fluxo.png b/fluxo.png index 98af480..12905cd 100644 Binary files a/fluxo.png and b/fluxo.png differ diff --git a/main.py b/main.py index 8d38037..7771df9 100644 --- a/main.py +++ b/main.py @@ -7,19 +7,52 @@ from services import ( summarize_text_if_needed, ) from models import WebhookRequest -import aiohttp -from config import settings, logger +from config import logger, settings, redis_client +from storage import StorageHandler +import traceback +import os app = FastAPI() +storage = StorageHandler() + +# Função para buscar configurações do Redis com fallback para valores padrão +def get_config(key, default=None): + try: + value = redis_client.get(key) + if value is None: + logger.warning(f"Configuração '{key}' não encontrada no Redis. Usando padrão: {default}") + return default + return value + except Exception as e: + logger.error(f"Erro ao acessar Redis: {e}") + return default + +# Carregando configurações dinâmicas do Redis +def load_dynamic_settings(): + return { + "GROQ_API_KEY": get_config("GROQ_API_KEY", "default_key"), + "BUSINESS_MESSAGE": get_config("BUSINESS_MESSAGE", "*Impacte AI* Premium Services"), + "PROCESS_GROUP_MESSAGES": get_config("PROCESS_GROUP_MESSAGES", "false") == "true", + "PROCESS_SELF_MESSAGES": get_config("PROCESS_SELF_MESSAGES", "true") == "true", + "DEBUG_MODE": get_config("DEBUG_MODE", "false") == "true", + } @app.post("/transcreve-audios") async def transcreve_audios(request: Request): try: - logger.info("Iniciando processamento de áudio") body = await request.json() + dynamic_settings = load_dynamic_settings() - if settings.DEBUG_MODE: - logger.debug(f"Payload recebido: {body}") + # Log inicial da requisição + storage.add_log("INFO", "Nova requisição de transcrição recebida", { + "instance": body.get("instance"), + "event": body.get("event") + }) + + if dynamic_settings["DEBUG_MODE"]: + storage.add_log("DEBUG", "Payload completo recebido", { + "body": body + }) # Extraindo informações server_url = body["server_url"] @@ -30,58 +63,101 @@ async def transcreve_audios(request: Request): remote_jid = body["data"]["key"]["remoteJid"] message_type = body["data"]["messageType"] + # Verificação de tipo de mensagem if "audioMessage" not in message_type: - logger.info("Mensagem recebida não é um áudio, ignorando") + storage.add_log("INFO", "Mensagem ignorada - não é áudio", { + "message_type": message_type, + "remote_jid": remote_jid + }) return {"message": "Mensagem recebida não é um áudio"} - if from_me and not settings.PROCESS_SELF_MESSAGES: - logger.info("Mensagem enviada pelo próprio usuário ignorada conforme configuração") + # Verificação de permissões + if not storage.can_process_message(remote_jid): + is_group = "@g.us" in remote_jid + storage.add_log("INFO", + "Mensagem não autorizada para processamento", + { + "remote_jid": remote_jid, + "tipo": "grupo" if is_group else "usuário", + "motivo": "grupo não permitido" if is_group else "usuário bloqueado" + } + ) + return {"message": "Mensagem não autorizada para processamento"} + + if from_me and not dynamic_settings["PROCESS_SELF_MESSAGES"]: + storage.add_log("INFO", "Mensagem própria ignorada", { + "remote_jid": remote_jid + }) return {"message": "Mensagem enviada por mim, sem operação"} - if "@g.us" in remote_jid and not settings.PROCESS_GROUP_MESSAGES: - logger.info("Mensagem de grupo ignorada conforme configuração") - return {"message": "Mensagem enviada por um grupo, sem operação"} + # Obter áudio + try: + if "mediaUrl" in body["data"]["message"]: + audio_source = body["data"]["message"]["mediaUrl"] + storage.add_log("DEBUG", "Usando mediaUrl para áudio", { + "mediaUrl": audio_source + }) + else: + storage.add_log("DEBUG", "Obtendo áudio via base64") + base64_audio = await get_audio_base64(server_url, instance, apikey, audio_key) + audio_source = await convert_base64_to_file(base64_audio) + storage.add_log("DEBUG", "Áudio convertido", { + "source": audio_source + }) - # Verificar se temos mediaUrl ou precisamos pegar o base64 - if "mediaUrl" in body["data"]["message"]: - audio_source = body["data"]["message"]["mediaUrl"] - logger.debug(f"Usando mediaUrl: {audio_source}") - else: - logger.debug("MediaUrl não encontrada, obtendo áudio via base64") - base64_audio = await get_audio_base64(server_url, instance, apikey, audio_key) - audio_source = await convert_base64_to_file(base64_audio) - logger.debug(f"Áudio convertido e salvo em: {audio_source}") + # Transcrever áudio + storage.add_log("INFO", "Iniciando transcrição") + transcription_text, _ = await transcribe_audio(audio_source) + + # Resumir se necessário + summary_text = await summarize_text_if_needed(transcription_text) + + # Formatar mensagem + summary_message = ( + f"🤖 *Resumo do áudio:*\n\n" + f"{summary_text}\n\n" + f"🔊 *Transcrição do áudio:*\n\n" + f"{transcription_text}\n\n" + f"{dynamic_settings['BUSINESS_MESSAGE']}" + ) - # Transcrever o áudio - transcription_text, _ = await transcribe_audio(audio_source) - summary_text = await summarize_text_if_needed(transcription_text) + # Enviar resposta + await send_message_to_whatsapp( + server_url, + instance, + apikey, + summary_message, + remote_jid, + audio_key, + ) - # Formatar a mensagem - summary_message = ( - f"🤖 *Resumo do áudio:*\n\n" - f"{summary_text}\n\n" - f"🔊 *Transcrição do áudio:*\n\n" - f"{transcription_text}\n\n" - f"{settings.BUSINESS_MESSAGE}" - ) - logger.debug(f"Mensagem formatada: {summary_message[:100]}...") + # Registrar sucesso + storage.record_processing(remote_jid) + storage.add_log("INFO", "Áudio processado com sucesso", { + "remote_jid": remote_jid, + "transcription_length": len(transcription_text), + "summary_length": len(summary_text) + }) - # Enviar a mensagem formatada via WhatsApp - await send_message_to_whatsapp( - server_url, - instance, - apikey, - summary_message, - remote_jid, - audio_key, - ) + return {"message": "Áudio transcrito e resposta enviada com sucesso"} - logger.info("Áudio processado e resposta enviada com sucesso") - return {"message": "Áudio transcrito e resposta enviada com sucesso"} + except Exception as e: + storage.add_log("ERROR", f"Erro ao processar áudio: {str(e)}", { + "error_type": type(e).__name__, + "remote_jid": remote_jid, + "traceback": traceback.format_exc() + }) + raise HTTPException( + status_code=500, + detail=f"Erro ao processar áudio: {str(e)}" + ) except Exception as e: - logger.error(f"Erro ao processar áudio: {str(e)}", exc_info=settings.DEBUG_MODE) + storage.add_log("ERROR", f"Erro na requisição: {str(e)}", { + "error_type": type(e).__name__, + "traceback": traceback.format_exc() + }) raise HTTPException( status_code=500, - detail=f"Erro ao processar a requisição: {str(e)}", + detail=f"Erro ao processar a requisição: {str(e)}" ) \ No newline at end of file diff --git a/manager.py b/manager.py new file mode 100644 index 0000000..c445085 --- /dev/null +++ b/manager.py @@ -0,0 +1,222 @@ +import streamlit as st +import pandas as pd +from datetime import datetime +from storage import StorageHandler +import plotly.express as px +import os +import redis + +# Conectar ao Redis +redis_client = redis.Redis(host=os.getenv('REDIS_HOST', 'localhost'), port=int(os.getenv('REDIS_PORT', 6380)), decode_responses=True) + +# Função para salvar configurações no Redis +def save_to_redis(key, value): + try: + redis_client.set(key, value) + st.success(f"Configuração {key} salva com sucesso!") + except Exception as e: + st.error(f"Erro ao salvar no Redis: {key} -> {e}") + +# Função para buscar configurações no Redis +def get_from_redis(key, default=None): + try: + value = redis_client.get(key) + return value if value is not None else default + except Exception as e: + st.error(f"Erro ao buscar no Redis: {key} -> {e}") + return default + +# Configuração da página +st.set_page_config( + page_title="TranscreveZAP by Impacte AI", + page_icon="🎙️", + layout="wide", + initial_sidebar_state="expanded", +) + +# Configuração do storage +storage = StorageHandler() + +# Função para carregar configurações do Redis para o Streamlit +def load_settings(): + try: + st.session_state.settings = { + "GROQ_API_KEY": get_from_redis("GROQ_API_KEY", "default_key"), + "BUSINESS_MESSAGE": get_from_redis("BUSINESS_MESSAGE", "*Impacte AI* Premium Services"), + "PROCESS_GROUP_MESSAGES": get_from_redis("PROCESS_GROUP_MESSAGES", "false"), + "PROCESS_SELF_MESSAGES": get_from_redis("PROCESS_SELF_MESSAGES", "true"), + } + except Exception as e: + st.error(f"Erro ao carregar configurações do Redis: {e}") + +# Carregar configurações na sessão, se necessário +if "settings" not in st.session_state: + load_settings() + +# Função para salvar configurações do Streamlit no Redis +def save_settings(): + try: + save_to_redis("GROQ_API_KEY", st.session_state.groq_api_key) + save_to_redis("BUSINESS_MESSAGE", st.session_state.business_message) + save_to_redis("PROCESS_GROUP_MESSAGES", st.session_state.process_group_messages) + save_to_redis("PROCESS_SELF_MESSAGES", st.session_state.process_self_messages) + st.success("Configurações salvas com sucesso!") + except Exception as e: + st.error(f"Erro ao salvar configurações: {e}") + +def show_logo(): + try: + logo_path = os.path.join(os.path.dirname(__file__), "static", "fluxo.png") + if os.path.exists(logo_path): + st.image(logo_path, width=150) + else: + st.warning("Logo não encontrada.") + except Exception as e: + st.error(f"Erro ao carregar logo: {e}") + +def show_footer(): + st.markdown( + """ + + """, + unsafe_allow_html=True, + ) + +def login_page(): + show_logo() + col1, col2, col3 = st.columns([1, 2, 1]) + with col2: + with st.form("login_form"): + st.markdown("

Login

", unsafe_allow_html=True) + username = st.text_input('Usuário') + password = st.text_input('Senha', type='password') + if st.form_submit_button('Entrar', use_container_width=True): + if username == os.getenv('MANAGER_USER') and password == os.getenv('MANAGER_PASSWORD'): + st.session_state.authenticated = True + st.experimental_rerun() + else: + st.error('Credenciais inválidas') + +def dashboard(): + show_logo() + st.sidebar.markdown('', unsafe_allow_html=True) + page = st.sidebar.radio( + "Navegação", + ["📊 Painel de Controle", "👥 Gerenciar Grupos", "🚫 Gerenciar Bloqueios", "⚙️ Configurações"] + ) + if st.sidebar.button("Sair"): + st.session_state.authenticated = False + st.experimental_rerun() + + if page == "📊 Painel de Controle": + show_statistics() + elif page == "👥 Gerenciar Grupos": + manage_groups() + elif page == "🚫 Gerenciar Bloqueios": + manage_blocks() + elif page == "⚙️ Configurações": + manage_settings() + +def show_statistics(): + st.title("📊 Painel de Controle") + try: + stats = storage.get_statistics() + col1, col2, col3 = st.columns(3) + with col1: + st.metric("Total de Áudios Processados", stats.get("total_processed", 0)) + with col2: + last_processed = stats.get("last_processed", "Nunca") + st.metric("Último Processamento", last_processed) + with col3: + total_groups = len(storage.get_allowed_groups()) + st.metric("Grupos Permitidos", total_groups) + + daily_data = stats["stats"]["daily_count"] + if daily_data: + df = pd.DataFrame(list(daily_data.items()), columns=['Data', 'Processamentos']) + df['Data'] = pd.to_datetime(df['Data']) + fig = px.line(df, x='Data', y='Processamentos', title='Processamentos por Dia') + st.plotly_chart(fig, use_container_width=True) + else: + st.info("Ainda não há dados de processamento disponíveis.") + except Exception as e: + st.error(f"Erro ao carregar estatísticas: {e}") + +def manage_groups(): + st.title("👥 Gerenciar Grupos") + st.subheader("Adicionar Grupo Permitido") + col1, col2 = st.columns([3, 1]) + with col1: + new_group = st.text_input("Número do Grupo", placeholder="Ex: 5521999999999") + with col2: + if st.button("Adicionar"): + formatted_group = f"{new_group}@g.us" + storage.add_allowed_group(formatted_group) + st.success(f"Grupo {formatted_group} adicionado com sucesso!") + st.experimental_rerun() + + st.subheader("Grupos Permitidos") + allowed_groups = storage.get_allowed_groups() + if allowed_groups: + for group in allowed_groups: + col1, col2 = st.columns([4, 1]) + with col1: + st.text(group) + with col2: + if st.button("Remover", key=f"remove_{group}"): + storage.remove_allowed_group(group) + st.success(f"Grupo {group} removido!") + st.experimental_rerun() + else: + st.info("Nenhum grupo permitido.") + +def manage_blocks(): + st.title("🚫 Gerenciar Bloqueios") + st.subheader("Bloquear Usuário") + col1, col2 = st.columns([3, 1]) + with col1: + new_user = st.text_input("Número do Usuário", placeholder="Ex: 5521999999999") + with col2: + if st.button("Bloquear"): + formatted_user = f"{new_user}@s.whatsapp.net" + storage.add_blocked_user(formatted_user) + st.success(f"Usuário {formatted_user} bloqueado!") + st.experimental_rerun() + + st.subheader("Usuários Bloqueados") + blocked_users = storage.get_blocked_users() + if blocked_users: + for user in blocked_users: + col1, col2 = st.columns([4, 1]) + with col1: + st.text(user) + with col2: + if st.button("Desbloquear", key=f"unblock_{user}"): + storage.remove_blocked_user(user) + st.success(f"Usuário {user} desbloqueado!") + st.experimental_rerun() + else: + st.info("Nenhum usuário bloqueado.") + +def manage_settings(): + st.title("⚙️ Configurações") + st.subheader("Configurações do Sistema") + st.text_input("GROQ_API_KEY", value=st.session_state.settings["GROQ_API_KEY"], key="groq_api_key") + st.text_input("Mensagem de Boas-Vindas", value=st.session_state.settings["BUSINESS_MESSAGE"], key="business_message") + st.selectbox("Processar Mensagens em Grupos", options=["true", "false"], index=["true", "false"].index(st.session_state.settings["PROCESS_GROUP_MESSAGES"]), key="process_group_messages") + st.selectbox("Processar Mensagens Próprias", options=["true", "false"], index=["true", "false"].index(st.session_state.settings["PROCESS_SELF_MESSAGES"]), key="process_self_messages") + if st.button("Salvar Configurações"): + save_settings() + +if "authenticated" not in st.session_state: + st.session_state.authenticated = False + +if st.session_state.authenticated: + dashboard() +else: + login_page() + +show_footer() \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index ceb0554..f989f43 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,21 +8,23 @@ attrs==24.2.0 certifi==2024.8.30 charset-normalizer==2.1.1 click==8.1.7 -fastapi==0.109.2 +fastapi==0.115.6 frozenlist==1.4.1 h11==0.14.0 idna==3.10 multidict==6.1.0 pip-system-certs==4.0 +plotly==5.18.0 propcache==0.2.0 pydantic==2.10.3 pydantic-settings==2.6.1 python-dotenv==1.0.1 requests==2.28.1 sniffio==1.3.1 -starlette>=0.36.3,<0.37.0 +streamlit==1.31.0 typing_extensions==4.12.2 urllib3==1.26.20 -uvicorn==0.17.6 +uvicorn==0.23.2 wrapt==1.16.0 yarl==1.15.2 +redis \ No newline at end of file diff --git a/services.py b/services.py index b0fbcee..4a04ff1 100644 --- a/services.py +++ b/services.py @@ -2,27 +2,40 @@ import aiohttp import base64 import aiofiles from fastapi import HTTPException -from config import settings, logger +from config import settings, logger, redis_client +from storage import StorageHandler +import os +import json +import tempfile + +# Inicializa o storage handler +storage = StorageHandler() async def convert_base64_to_file(base64_data): """Converte dados base64 em arquivo temporário""" try: - logger.debug("Iniciando conversão de base64 para arquivo") + storage.add_log("DEBUG", "Iniciando conversão de base64 para arquivo") audio_data = base64.b64decode(base64_data) - audio_file_path = "/tmp/audio_file.mp3" - - async with aiofiles.open(audio_file_path, "wb") as f: - await f.write(audio_data) + with tempfile.NamedTemporaryFile(delete=False, suffix=".mp3") as temp_file: + temp_file.write(audio_data) + audio_file_path = temp_file.name - logger.debug(f"Arquivo temporário criado em: {audio_file_path}") + storage.add_log("DEBUG", "Arquivo temporário criado", { + "path": audio_file_path + }) return audio_file_path except Exception as e: - logger.error(f"Erro na conversão base64: {str(e)}", exc_info=settings.DEBUG_MODE) + storage.add_log("ERROR", "Erro na conversão base64", { + "error": str(e), + "type": type(e).__name__ + }) raise async def summarize_text_if_needed(text): """Resumir texto usando a API GROQ""" - logger.debug("Iniciando processo de resumo do texto") + storage.add_log("DEBUG", "Iniciando processo de resumo", { + "text_length": len(text) + }) url_completions = "https://api.groq.com/openai/v1/chat/completions" headers = { @@ -50,25 +63,33 @@ async def summarize_text_if_needed(text): try: async with aiohttp.ClientSession() as session: - logger.debug("Enviando requisição para API GROQ") + storage.add_log("DEBUG", "Enviando requisição para API GROQ") async with session.post(url_completions, headers=headers, json=json_data) as summary_response: if summary_response.status == 200: summary_result = await summary_response.json() summary_text = summary_result["choices"][0]["message"]["content"] - logger.info("Resumo gerado com sucesso") - logger.debug(f"Resumo: {summary_text[:100]}...") + storage.add_log("INFO", "Resumo gerado com sucesso", { + "original_length": len(text), + "summary_length": len(summary_text) + }) return summary_text else: error_text = await summary_response.text() - logger.error(f"Erro na API GROQ: {error_text}") + storage.add_log("ERROR", "Erro na API GROQ", { + "error": error_text, + "status": summary_response.status + }) raise Exception(f"Erro ao resumir o texto: {error_text}") except Exception as e: - logger.error(f"Erro no processo de resumo: {str(e)}", exc_info=settings.DEBUG_MODE) + storage.add_log("ERROR", "Erro no processo de resumo", { + "error": str(e), + "type": type(e).__name__ + }) raise async def transcribe_audio(audio_source, apikey=None): """Transcreve áudio usando a API GROQ""" - logger.info("Iniciando processo de transcrição") + storage.add_log("INFO", "Iniciando processo de transcrição") url = "https://api.groq.com/openai/v1/audio/transcriptions" groq_headers = {"Authorization": f"Bearer {settings.GROQ_API_KEY}"} @@ -76,21 +97,27 @@ async def transcribe_audio(audio_source, apikey=None): async with aiohttp.ClientSession() as session: # Se o audio_source for uma URL if isinstance(audio_source, str) and audio_source.startswith('http'): - logger.debug(f"Baixando áudio da URL: {audio_source}") + storage.add_log("DEBUG", "Baixando áudio da URL", { + "url": audio_source + }) download_headers = {"apikey": apikey} if apikey else {} async with session.get(audio_source, headers=download_headers) as response: if response.status != 200: error_text = await response.text() - logger.error(f"Erro no download do áudio: Status {response.status}, Resposta: {error_text}") + storage.add_log("ERROR", "Erro no download do áudio", { + "status": response.status, + "error": error_text + }) raise Exception(f"Erro ao baixar áudio: {error_text}") audio_data = await response.read() - temp_file = "/tmp/audio_from_url.mp3" - async with aiofiles.open(temp_file, "wb") as f: - await f.write(audio_data) - audio_source = temp_file - logger.debug(f"Áudio salvo temporariamente em: {temp_file}") + with tempfile.NamedTemporaryFile(delete=False, suffix=".mp3") as temp_file: + temp_file.write(audio_data) + audio_source = temp_file.name + storage.add_log("DEBUG", "Áudio salvo temporariamente", { + "path": audio_source + }) # Preparar dados para transcrição data = aiohttp.FormData() @@ -98,51 +125,73 @@ async def transcribe_audio(audio_source, apikey=None): data.add_field('model', 'whisper-large-v3') data.add_field('language', 'pt') - logger.debug("Enviando áudio para transcrição") + storage.add_log("DEBUG", "Enviando áudio para transcrição") async with session.post(url, headers=groq_headers, data=data) as response: if response.status == 200: result = await response.json() message = result.get("text", "") - logger.info("Transcrição concluída com sucesso") - logger.debug(f"Texto transcrito: {message[:100]}...") + storage.add_log("INFO", "Transcrição concluída com sucesso", { + "text_length": len(message) + }) is_summary = False if len(message) > 1000: - logger.debug("Texto longo detectado, iniciando resumo") + storage.add_log("DEBUG", "Texto longo detectado, iniciando resumo", { + "text_length": len(message) + }) is_summary = True message = await summarize_text_if_needed(message) return message, is_summary else: error_text = await response.text() - logger.error(f"Erro na transcrição: {error_text}") + storage.add_log("ERROR", "Erro na transcrição", { + "error": error_text, + "status": response.status + }) raise Exception(f"Erro na transcrição: {error_text}") except Exception as e: - logger.error(f"Erro no processo de transcrição: {str(e)}", exc_info=settings.DEBUG_MODE) + storage.add_log("ERROR", "Erro no processo de transcrição", { + "error": str(e), + "type": type(e).__name__ + }) raise + finally: + # Limpar arquivos temporários + if isinstance(audio_source, str) and os.path.exists(audio_source): + os.unlink(audio_source) async def send_message_to_whatsapp(server_url, instance, apikey, message, remote_jid, message_id): """Envia mensagem via WhatsApp""" - logger.debug(f"Preparando envio de mensagem para: {remote_jid}") + storage.add_log("DEBUG", "Preparando envio de mensagem", { + "remote_jid": remote_jid, + "instance": instance + }) url = f"{server_url}/message/sendText/{instance}" headers = {"apikey": apikey} try: # Tentar enviar na V1 body = get_body_message_to_whatsapp_v1(message, remote_jid) - logger.debug("Tentando envio no formato V1") + storage.add_log("DEBUG", "Tentando envio no formato V1") result = await call_whatsapp(url, body, headers) # Se falhar, tenta V2 if not result: - logger.debug("Formato V1 falhou, tentando formato V2") + storage.add_log("DEBUG", "Formato V1 falhou, tentando formato V2") body = get_body_message_to_whatsapp_v2(message, remote_jid, message_id) await call_whatsapp(url, body, headers) - logger.info("Mensagem enviada com sucesso") + storage.add_log("INFO", "Mensagem enviada com sucesso", { + "remote_jid": remote_jid + }) except Exception as e: - logger.error(f"Erro no envio da mensagem: {str(e)}", exc_info=settings.DEBUG_MODE) + storage.add_log("ERROR", "Erro no envio da mensagem", { + "error": str(e), + "type": type(e).__name__, + "remote_jid": remote_jid + }) raise def get_body_message_to_whatsapp_v1(message, remote_jid): @@ -165,21 +214,32 @@ async def call_whatsapp(url, body, headers): """Realiza chamada à API do WhatsApp""" try: async with aiohttp.ClientSession() as session: - logger.debug(f"Enviando requisição para: {url}") + storage.add_log("DEBUG", "Enviando requisição para WhatsApp", { + "url": url + }) async with session.post(url, json=body, headers=headers) as response: if response.status not in [200, 201]: error_text = await response.text() - logger.error(f"Erro na API do WhatsApp: Status {response.status}, Resposta: {error_text}") + storage.add_log("ERROR", "Erro na API do WhatsApp", { + "status": response.status, + "error": error_text + }) return False - logger.debug("Requisição bem-sucedida") + storage.add_log("DEBUG", "Requisição bem-sucedida") return True except Exception as e: - logger.error(f"Erro na chamada WhatsApp: {str(e)}", exc_info=settings.DEBUG_MODE) + storage.add_log("ERROR", "Erro na chamada WhatsApp", { + "error": str(e), + "type": type(e).__name__ + }) return False async def get_audio_base64(server_url, instance, apikey, message_id): """Obtém áudio em Base64 via API do WhatsApp""" - logger.debug(f"Obtendo áudio base64 para mensagem: {message_id}") + storage.add_log("DEBUG", "Obtendo áudio base64", { + "message_id": message_id, + "instance": instance + }) url = f"{server_url}/chat/getBase64FromMediaMessage/{instance}" headers = {"apikey": apikey} body = {"message": {"key": {"id": message_id}}, "convertToMp4": False} @@ -189,12 +249,19 @@ async def get_audio_base64(server_url, instance, apikey, message_id): async with session.post(url, json=body, headers=headers) as response: if response.status in [200, 201]: result = await response.json() - logger.info("Áudio base64 obtido com sucesso") + storage.add_log("INFO", "Áudio base64 obtido com sucesso") return result.get("base64", "") else: error_text = await response.text() - logger.error(f"Erro ao obter áudio base64: {error_text}") + storage.add_log("ERROR", "Erro ao obter áudio base64", { + "status": response.status, + "error": error_text + }) raise HTTPException(status_code=500, detail="Falha ao obter áudio em base64") except Exception as e: - logger.error(f"Erro na obtenção do áudio base64: {str(e)}", exc_info=settings.DEBUG_MODE) + storage.add_log("ERROR", "Erro na obtenção do áudio base64", { + "error": str(e), + "type": type(e).__name__, + "message_id": message_id + }) raise \ No newline at end of file diff --git a/start.sh b/start.sh new file mode 100644 index 0000000..e37f6ab --- /dev/null +++ b/start.sh @@ -0,0 +1,22 @@ +#!/bin/bash + +# Função para inicializar configurações no Redis +initialize_redis_config() { + redis-cli -h $REDIS_HOST -p $REDIS_PORT SET GROQ_API_KEY "sua_api_key_aqui" + redis-cli -h $REDIS_HOST -p $REDIS_PORT SET BUSINESS_MESSAGE "*Impacte AI* Premium Services" + redis-cli -h $REDIS_HOST -p $REDIS_PORT SET PROCESS_GROUP_MESSAGES "false" + redis-cli -h $REDIS_HOST -p $REDIS_PORT SET PROCESS_SELF_MESSAGES "true" + redis-cli -h $REDIS_HOST -p $REDIS_PORT SET DEBUG_MODE "false" +} + +# Inicializar configurações no Redis +initialize_redis_config + +# Iniciar o FastAPI em background +uvicorn main:app --host 0.0.0.0 --port 8005 & + +# Iniciar o Streamlit +streamlit run manager.py --server.address 0.0.0.0 --server.port 8501 + +# Manter o script rodando +wait \ No newline at end of file diff --git a/static/fluxo.png b/static/fluxo.png new file mode 100644 index 0000000..12905cd Binary files /dev/null and b/static/fluxo.png differ diff --git a/storage.py b/storage.py new file mode 100644 index 0000000..862caf6 --- /dev/null +++ b/storage.py @@ -0,0 +1,171 @@ +import json +import os +from typing import List, Dict +from datetime import datetime, timedelta +import traceback +import logging +import redis + +class StorageHandler: + def __init__(self): + # Configuração de logger + self.logger = logging.getLogger("StorageHandler") + handler = logging.StreamHandler() + formatter = logging.Formatter( + '%(asctime)s - %(name)s - %(levelname)s - %(message)s' + ) + handler.setFormatter(formatter) + self.logger.addHandler(handler) + self.logger.setLevel(logging.DEBUG) + self.logger.info("StorageHandler inicializado.") + + # Conexão com o Redis + self.redis = redis.Redis( + host=os.getenv('REDIS_HOST', 'localhost'), + port=int(os.getenv('REDIS_PORT', 6380)), + db=0, + decode_responses=True + ) + + # Retenção de logs e backups + self.log_retention_hours = int(os.getenv('LOG_RETENTION_HOURS', 48)) + self.backup_retention_days = int(os.getenv('BACKUP_RETENTION_DAYS', 7)) + + def _get_redis_key(self, key): + return f"transcrevezap:{key}" + + def add_log(self, level: str, message: str, metadata: dict = None): + log_entry = { + "timestamp": datetime.now().isoformat(), + "level": level, + "message": message, + "metadata": json.dumps(metadata) if metadata else None + } + self.redis.lpush(self._get_redis_key("logs"), json.dumps(log_entry)) + self.redis.ltrim(self._get_redis_key("logs"), 0, 999) # Manter apenas os últimos 1000 logs + self.logger.log(getattr(logging, level.upper(), logging.INFO), f"{message} | Metadata: {metadata}") + + def get_allowed_groups(self) -> List[str]: + return self.redis.smembers(self._get_redis_key("allowed_groups")) + + def add_allowed_group(self, group: str): + self.redis.sadd(self._get_redis_key("allowed_groups"), group) + + def remove_allowed_group(self, group: str): + self.redis.srem(self._get_redis_key("allowed_groups"), group) + + def get_blocked_users(self) -> List[str]: + return self.redis.smembers(self._get_redis_key("blocked_users")) + + def add_blocked_user(self, user: str): + self.redis.sadd(self._get_redis_key("blocked_users"), user) + + def remove_blocked_user(self, user: str): + self.redis.srem(self._get_redis_key("blocked_users"), user) + + def get_statistics(self) -> Dict: + total_processed = int(self.redis.get(self._get_redis_key("total_processed")) or 0) + last_processed = self.redis.get(self._get_redis_key("last_processed")) + daily_count = json.loads(self.redis.get(self._get_redis_key("daily_count")) or "{}") + group_count = json.loads(self.redis.get(self._get_redis_key("group_count")) or "{}") + user_count = json.loads(self.redis.get(self._get_redis_key("user_count")) or "{}") + error_count = int(self.redis.get(self._get_redis_key("error_count")) or 0) + success_rate = float(self.redis.get(self._get_redis_key("success_rate")) or 100.0) + + return { + "total_processed": total_processed, + "last_processed": last_processed, + "stats": { + "daily_count": daily_count, + "group_count": group_count, + "user_count": user_count, + "error_count": error_count, + "success_rate": success_rate, + } + } + + def can_process_message(self, remote_jid): + try: + allowed_groups = self.get_allowed_groups() + blocked_users = self.get_blocked_users() + + if remote_jid in blocked_users: + return False + if "@g.us" in remote_jid and remote_jid not in allowed_groups: + return False + + return True + except Exception as e: + self.logger.error(f"Erro ao verificar se pode processar mensagem: {e}") + return False + + def record_processing(self, remote_jid): + try: + # Incrementar total processado + self.redis.incr(self._get_redis_key("total_processed")) + + # Atualizar último processamento + self.redis.set(self._get_redis_key("last_processed"), datetime.now().isoformat()) + + # Atualizar contagem diária + today = datetime.now().strftime("%Y-%m-%d") + daily_count = json.loads(self.redis.get(self._get_redis_key("daily_count")) or "{}") + daily_count[today] = daily_count.get(today, 0) + 1 + self.redis.set(self._get_redis_key("daily_count"), json.dumps(daily_count)) + + # Atualizar contagem de grupo ou usuário + if "@g.us" in remote_jid: + group_count = json.loads(self.redis.get(self._get_redis_key("group_count")) or "{}") + group_count[remote_jid] = group_count.get(remote_jid, 0) + 1 + self.redis.set(self._get_redis_key("group_count"), json.dumps(group_count)) + else: + user_count = json.loads(self.redis.get(self._get_redis_key("user_count")) or "{}") + user_count[remote_jid] = user_count.get(remote_jid, 0) + 1 + self.redis.set(self._get_redis_key("user_count"), json.dumps(user_count)) + + # Atualizar taxa de sucesso + total = int(self.redis.get(self._get_redis_key("total_processed")) or 0) + errors = int(self.redis.get(self._get_redis_key("error_count")) or 0) + success_rate = ((total - errors) / total) * 100 if total > 0 else 100 + self.redis.set(self._get_redis_key("success_rate"), success_rate) + + except Exception as e: + self.logger.error(f"Erro ao registrar processamento: {e}") + + def record_error(self): + self.redis.incr(self._get_redis_key("error_count")) + + def clean_old_logs(self): + try: + cutoff_time = datetime.now() - timedelta(hours=self.log_retention_hours) + logs = self.redis.lrange(self._get_redis_key("logs"), 0, -1) + for log in logs: + log_entry = json.loads(log) + if datetime.fromisoformat(log_entry["timestamp"]) < cutoff_time: + self.redis.lrem(self._get_redis_key("logs"), 0, log) + else: + break # Assumindo que os logs estão ordenados por tempo + except Exception as e: + self.logger.error(f"Erro ao limpar logs antigos: {e}") + + def backup_data(self): + try: + data = { + "allowed_groups": list(self.get_allowed_groups()), + "blocked_users": list(self.get_blocked_users()), + "statistics": self.get_statistics(), + } + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + backup_key = f"backup:{timestamp}" + self.redis.set(backup_key, json.dumps(data)) + self.redis.expire(backup_key, self.backup_retention_days * 24 * 60 * 60) # Expira após os dias de retenção + except Exception as e: + self.logger.error(f"Erro ao criar backup: {e}") + + def clean_old_backups(self): + try: + for key in self.redis.scan_iter("backup:*"): + if self.redis.ttl(key) <= 0: + self.redis.delete(key) + except Exception as e: + self.logger.error(f"Erro ao limpar backups antigos: {e}") \ No newline at end of file diff --git a/transcription_logs.json b/transcription_logs.json new file mode 100644 index 0000000..e69de29