diff --git a/app/__pycache__/config.cpython-312.pyc b/app/__pycache__/config.cpython-312.pyc index 6c5b2dd..43be48d 100644 Binary files a/app/__pycache__/config.cpython-312.pyc and b/app/__pycache__/config.cpython-312.pyc differ diff --git a/app/active_sessions.json b/app/active_sessions.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/app/active_sessions.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/app/config.py b/app/config.py index 831d36d..e650c42 100644 --- a/app/config.py +++ b/app/config.py @@ -9,7 +9,10 @@ VERIFY_TOKEN = os.getenv("WEBHOOK_VERIFICATION_TOKEN") WHATSAPP_ACCESS_TOKEN = os.getenv("WHATSAPP_ACCESS_TOKEN") WHATSAPP_PHONE_NUMBER_ID = os.getenv("WHATSAPP_PHONE_NUMBER_ID") FLOW_PRIVATE_KEY_PASSWORD = os.getenv("FLOW_PRIVATE_KEY_PASSWORD") +DATABASE_ODBC_CONN_STR = os.getenv("DATABASE_ODBC_CONN_STR") +if not DATABASE_ODBC_CONN_STR: + raise ValueError("DATABASE_ODBC_CONN_STR não está definida no arquivo .env") # Verificação para garantir que as variáveis críticas estão presentes if not VERIFY_TOKEN: raise ValueError("WEBHOOK_VERIFICATION_TOKEN não está definido no .env") diff --git a/app/dialog_flow/graph_definition.py b/app/dialog_flow/graph_definition.py index d1ba053..5587526 100644 --- a/app/dialog_flow/graph_definition.py +++ b/app/dialog_flow/graph_definition.py @@ -13,28 +13,73 @@ DIALOG_GRAPH: Dict[str, Dict[str, Any]] = { }, - "MENU_PRINCIPAL": { - "message": "*Olá você inicou o Consultme!:*", - "action_to_perform": "send_main_menu", # Ação para enviar um menu interativo (botões) - "expected_input_type": "button_click", + "MENU_PRINCIPAL": { + "message": "*Olá, você iniciou o Consultme!:*", + "action_to_perform": "send_main_menu", # Ação para enviar o menu principal (lista) + "expected_input_type": "list_reply", # Espera um clique em um item da lista "transitions": { - "OPTION_AGENDAR": "MENU_PRINCIPAL_STORE", - "OPTION_CADASTRO_FLOW": "MENU_PRINCIPAL_STORE", - "OPTION_STATUS": "MENU_PRINCIPAL_STORE", - "OPTION_FALAR_ATENDENTE": "MENU_PRINCIPAL_STORE", - "default": "MENU_PRINCIPAL_STORE" # Volta para o menu se a opção não for reconhecida + # Transições para os estados intermediários de tempo/período + "OPTION_ANO": "MENU_PRINCIPAL_ANO", + "OPTION_MES": "MENU_PRINCIPAL_MES", + "OPTION_ONTEM": "MENU_PRINCIPAL_ONTEM", + "OPTION_HOJE": "MENU_PRINCIPAL_HOJE", + "OPTION_SAIR": "ENCERRAR_CONVERSA", + "default": "RESPOSTA_NAO_ENTENDIDA" # Volta para o menu se a opção não for reconhecida } }, - "MENU_PRINCIPAL_STORE": { - "action_to_perform": "send_main_store", # Ação para enviar um menu interativo (botões) + "ENCERRAR_CONVERSA": { + "message": "Você encerrou o Chat, digite algo caso precise consultar novamente.", + "action_to_perform": "saindo_da_sessao" # Ação para enviar o menu principal (lista) + }, + + # --- ESTADOS INTERMEDIÁRIOS APÓS ESCOLHA DO PERÍODO (MANDAM O SEGUNDO MENU) --- + "MENU_PRINCIPAL_ANO": { + "message": "Você escolheu o *Ano*. Agora, qual indicador deseja visualizar?", + "action_to_perform": "send_main_store", "expected_input_type": "button_click", "transitions": { - "OPTION_AGENDAR": "AGENDAMENTO_INICIO", - "OPTION_CADASTRO_FLOW": "INICIAR_FLOW_CADASTRO", - "OPTION_STATUS": "PEDIR_NUMERO_PEDIDO", - "OPTION_FALAR_ATENDENTE": "ENCAMINHAR_ATENDENTE", - "default": "MENU_PRINCIPAL" # Volta para o menu se a opção não for reconhecida + "OPTION_TOTAL_CP": "RESPOSTA_ANO_TOTAL_CP", # Combinação final + "OPTION_TOTAL_LOJAS": "RESPOSTA_ANO_TOTAL_LOJAS", + "OPTION_TOTAL_UMA_LOJA": "RESPOSTA_ANO_UMA_LOJA", + "OPTION_SAIR": "ENCERRAR_CONVERSA", + "default": "RESPOSTA_NAO_ENTENDIDA" # Se não reconhecer, não entendi + } + }, + "MENU_PRINCIPAL_MES": { + "message": "Você escolheu o *Mês*. Agora, qual indicador deseja visualizar?", + "action_to_perform": "send_main_store", + "expected_input_type": "button_click", + "transitions": { + "OPTION_TOTAL_CP": "RESPOSTA_MES_TOTAL_CP", + "OPTION_TOTAL_LOJAS": "RESPOSTA_MES_TOTAL_LOJAS", + "OPTION_TOTAL_UMA_LOJA": "RESPOSTA_MES_UMA_LOJA", + "OPTION_SAIR": "ENCERRAR_CONVERSA", + "default": "RESPOSTA_NAO_ENTENDIDA" + } + }, + "MENU_PRINCIPAL_ONTEM": { + "message": "Você escolheu *Ontem*. Agora, qual indicador deseja visualizar?", + "action_to_perform": "send_main_store", + "expected_input_type": "button_click", + "transitions": { + "OPTION_TOTAL_CP": "RESPOSTA_ONTEM_TOTAL_CP", + "OPTION_TOTAL_LOJAS": "RESPOSTA_ONTEM_TOTAL_LOJAS", + "OPTION_TOTAL_UMA_LOJA": "RESPOSTA_ONTEM_UMA_LOJA", + "OPTION_SAIR": "ENCERRAR_CONVERSA", + "default": "RESPOSTA_NAO_ENTENDIDA" + } + }, + "MENU_PRINCIPAL_HOJE": { + "message": "Você escolheu *Hoje*. Agora, qual indicador deseja visualizar?", + "action_to_perform": "send_main_store", + "expected_input_type": "button_click", + "transitions": { + "OPTION_TOTAL_CP": "RESPOSTA_HOJE_TOTAL_CP", + "OPTION_TOTAL_LOJAS": "RESPOSTA_HOJE_TOTAL_LOJAS", + "OPTION_TOTAL_UMA_LOJA": "RESPOSTA_HOJE_UMA_LOJA", + "OPTION_SAIR": "ENCERRAR_CONVERSA", + "default": "RESPOSTA_NAO_ENTENDIDA" } }, @@ -44,97 +89,149 @@ DIALOG_GRAPH: Dict[str, Dict[str, Any]] = { "transitions": { "menu": "MENU_PRINCIPAL", "ajuda": "MENU_PRINCIPAL", - "default": "RESPOSTA_NAO_ENTENDIDA" # Continua não entendendo + "default": "RESPOSTA_NAO_ENTENDIDA" } }, - # --- FLUXO DE CADASTRO (com Flow) --- - "INICIAR_FLOW_CADASTRO": { - "action_to_perform": "send_flow_cadastro", # Ação para enviar o Flow - "flow_id": "COLOQUE_AQUI_O_FLOW_ID_DO_SEU_CADASTRO_PUBLICADO", # ID do seu Flow publicado - "flow_cta": "Abrir Cadastro", - "expected_input_type": "flow_nfm_reply", # Espera a resposta do Flow - "transitions": { - "success": "CADASTRO_CONCLUIDO", # Se o Flow for concluído com sucesso - "failure": "CADASTRO_FALHA" # Se houver um problema no Flow (ou o usuário não preencher) - } - }, - "CADASTRO_CONCLUIDO": { - "message": "Obrigado por se cadastrar, ${nome_completo}! Seu e-mail é: ${email}. Já pode explorar nossos serviços!", - "action_to_perform": "process_cadastro_data", # Ação para salvar no BD/CRM - "expected_input_type": "any", # Qualquer coisa leva ao menu - "transitions": { - "default": "MENU_PRINCIPAL" - } - }, - "CADASTRO_FALHA": { - "message": "Não foi possível completar seu cadastro. Por favor, tente novamente ou digite 'ajuda'.", - "expected_input_type": "text", - "transitions": { - "default": "MENU_PRINCIPAL" - } - }, - # --- FLUXO DE AGENDAMENTO (Exemplo Básico) --- - "AGENDAMENTO_INICIO": { - "message": "Certo! Para agendar, qual serviço você precisa?", - "expected_input_type": "text", - "transitions": { - "default": "AGENDAMENTO_CONFIRMAR_SERVICO" - } - }, - "AGENDAMENTO_CONFIRMAR_SERVICO": { - "message": "Você precisa de *${servico_agendado}*. Qual data e horário você prefere?", - "expected_input_type": "text", - "transitions": { - "default": "AGENDAMENTO_FINALIZAR" - }, - "action_to_perform": "save_temp_service" # Ação para guardar o serviço temporariamente - }, - "AGENDAMENTO_FINALIZAR": { - "message": "Seu agendamento para *${servico_agendado}* em *${data_horario_agendado}* foi confirmado! Te vejo lá!", - "action_to_perform": "confirm_appointment", # Ação para agendar no sistema real + + # --- ESTADOS FINAIS COMBINATÓRIOS (12 ESTADOS) --- + # Período: ANO + "RESPOSTA_ANO_TOTAL_CP": { + "message": " Consulta realizada com sucesso! \n\n💰 O realizado da Receita em GMV é: *${receita}* e o Boleto Médio é: *${boleto}*", + "action_to_perform": "get_combined_indicator_data_ano_cp", # Ação para buscar e formatar dados "expected_input_type": "any", - "transitions": { - "default": "MENU_PRINCIPAL" - } + "transitions": {"default": "MENU_PRINCIPAL"} }, - # --- FLUXO DE STATUS DO PEDIDO --- - "PEDIR_NUMERO_PEDIDO": { - "message": "Por favor, digite o número do seu pedido para consultar o status:", - "expected_input_type": "text", - "transitions": { - "default": "CONSULTAR_STATUS_API" - } - }, - "CONSULTAR_STATUS_API": { - "message": "Consultando o status do pedido *${numero_pedido}*...", - "action_to_perform": "call_external_status_api", # Ação para chamar um sistema externo (API futura) - "expected_input_type": "api_response", # A resposta vem de uma API, não do usuário - "transitions": { - "success": "STATUS_EXIBIR", - "failure": "STATUS_NAO_ENCONTRADO" - } - }, - "STATUS_EXIBIR": { - "message": "O status do pedido *${numero_pedido}* é: *${status_retornado}*.", + "RESPOSTA_ANO_TOTAL_LOJAS": { + "message": " Consulta realizada com sucesso! \n\n💰 O realizado da Receita em GMV é: *${receita}* e o Boleto Médio é: *${boleto}*", + "action_to_perform": "get_combined_indicator_data_ano_lojas", "expected_input_type": "any", - "transitions": { - "default": "MENU_PRINCIPAL" - } + "transitions": {"default": "MENU_PRINCIPAL"} }, - "STATUS_NAO_ENCONTRADO": { - "message": "Não consegui encontrar o pedido *${numero_pedido}*. Verifique e digite novamente, ou 'menu'.", + "RESPOSTA_ANO_UMA_LOJA": { + "message": "Você escolheu o Total de uma Loja do *Ano*. Por favor, digite o ID da loja.", + "action_to_perform": "set_context_for_store_id_input_ano_loja", # Ação para guardar o contexto de "Ano" e esperar o ID da loja "expected_input_type": "text", "transitions": { - "menu": "MENU_PRINCIPAL", - "default": "PEDIR_NUMERO_PEDIDO" + "default": "PROCESSAR_ID_LOJA_ANO" # Leva ao processamento do ID da loja } }, - # --- FLUXO DE ATENDENTE --- - "ENCAMINHAR_ATENDENTE": { - "message": "Encaminhando você para um de nossos atendentes. Por favor, aguarde.", - "terminal": True # Indica que a conversa termina aqui (até o atendente assumir) + + # Período: MÊS + "RESPOSTA_MES_TOTAL_CP": { + "message": " Consulta realizada com sucesso! \n\n💰 O realizado da Receita em GMV é: *${receita}* e o Boleto Médio é: *${boleto}*", + "action_to_perform": "get_combined_indicator_data_mes_cp", + "expected_input_type": "any", + "transitions": {"default": "MENU_PRINCIPAL"} }, - # ... Adicione mais estados conforme a complexidade da sua conversa ... + "RESPOSTA_MES_TOTAL_LOJAS": { + "message": " Consulta realizada com sucesso! \n\n💰 O realizado da Receita em GMV é: *${receita}* e o Boleto Médio é: *${boleto}*", + "action_to_perform": "get_combined_indicator_data_mes_lojas", + "expected_input_type": "any", + "transitions": {"default": "MENU_PRINCIPAL"} + }, + "RESPOSTA_MES_UMA_LOJA": { + "message": "Você escolheu o Total de uma Loja do *Mês*. Por favor, digite o ID da loja.", + "action_to_perform": "set_context_for_store_id_input_mes_loja", + "expected_input_type": "text", + "transitions": { + "default": "PROCESSAR_ID_LOJA_MES" + } + }, + + # Período: ONTEM + "RESPOSTA_ONTEM_TOTAL_CP": { + "message": " Consulta realizada com sucesso! \n\n💰 O realizado da Receita em GMV é: *${receita}* e o Boleto Médio é: *${boleto}*", + "action_to_perform": "get_combined_indicator_data_ontem_cp", + "expected_input_type": "any", + "transitions": {"default": "MENU_PRINCIPAL"} + }, + "RESPOSTA_ONTEM_TOTAL_LOJAS": { + "message": " Consulta realizada com sucesso! \n\n💰 O realizado da Receita em GMV é: *${receita}* e o Boleto Médio é: *${boleto}*", + "action_to_perform": "get_combined_indicator_data_ontem_lojas", + "expected_input_type": "any", + "transitions": {"default": "MENU_PRINCIPAL"} + }, + "RESPOSTA_ONTEM_UMA_LOJA": { + "message": "Você escolheu o Total de uma Loja de *Ontem*. Por favor, digite o ID da loja.", + "action_to_perform": "set_context_for_store_id_input_ontem_loja", + "expected_input_type": "text", + "transitions": { + "default": "PROCESSAR_ID_LOJA_ONTEM" + } + }, + + # Período: HOJE + "RESPOSTA_HOJE_TOTAL_CP": { + "message": " Consulta realizada com sucesso! \n\n💰 O realizado da Receita em GMV é: *${receita}* e o Boleto Médio é: *${boleto}*", + "action_to_perform": "get_combined_indicator_data_hoje_cp", + "expected_input_type": "any", + "transitions": {"default": "MENU_PRINCIPAL"} + }, + "RESPOSTA_HOJE_TOTAL_LOJAS": { + "message": " Consulta realizada com sucesso! \n\n💰 O realizado da Receita em GMV é: *${receita}* e o Boleto Médio é: *${boleto}*", + "action_to_perform": "get_combined_indicator_data_hoje_lojas", + "expected_input_type": "any", + "transitions": {"default": "MENU_PRINCIPAL"} + }, + "RESPOSTA_HOJE_UMA_LOJA": { + "message": "Você escolheu o Total de uma Loja de *Hoje*. Por favor, digite o ID da loja.", + "action_to_perform": "set_context_for_store_id_input_hoje_loja", + "expected_input_type": "text", + "transitions": { + "default": "PROCESSAR_ID_LOJA_HOJE" + } + }, + + # --- ESTADOS PARA PROCESSAR O ID DA LOJA (PARA 'UMA LOJA') --- + "PROCESSAR_ID_LOJA_ANO": { + "message": "Consultando indicador da loja ${id_loja} para o *Ano*...", + "action_to_perform": "get_store_indicator_ano", + "expected_input_type": "api_response", # Ação para pegar o ID digitado e consultar a API + "transitions": { + "success": "EXIBIR_INDICADOR_LOJA_ANO", + "failure": "LOJA_NAO_ENCONTRADA_ANO" + } + }, + "PROCESSAR_ID_LOJA_MES": { + "message": "Consultando indicador da loja ${id_loja} para o *Mês*...", + "action_to_perform": "get_store_indicator_mes", + "expected_input_type": "api_response", + "transitions": { + "success": "EXIBIR_INDICADOR_LOJA_MES", + "failure": "LOJA_NAO_ENCONTRADA_MES" + } + }, + "PROCESSAR_ID_LOJA_ONTEM": { + "message": "Consultando indicador da loja ${id_loja} para *Ontem*...", + "action_to_perform": "get_store_indicator_ontem", + "expected_input_type": "api_response", + "transitions": { + "success": "EXIBIR_INDICADOR_LOJA_ONTEM", + "failure": "LOJA_NAO_ENCONTRADA_ONTEM" + } + }, + "PROCESSAR_ID_LOJA_HOJE": { + "message": "Consultando indicador da loja ${id_loja} para *Hoje*...", + "action_to_perform": "get_store_indicator_hoje", + "expected_input_type": "api_response", + "transitions": { + "success": "EXIBIR_INDICADOR_LOJA_HOJE", + "failure": "LOJA_NAO_ENCONTRADA_HOJE" + } + }, + + # --- ESTADOS PARA EXIBIR INDICADOR DE LOJA ESPECÍFICA (COMBINAÇÕES) --- + "EXIBIR_INDICADOR_LOJA_ANO": { + "message": "O indicador da loja *${id_loja}* do *Ano* é: *${indicador_loja}*.", + "expected_input_type": "any", + "transitions": {"default": "MENU_PRINCIPAL"} + }, + "LOJA_NAO_ENCONTRADA_ANO": { + "message": "Loja *${id_loja}* não encontrada para o *Ano*. Digite novamente ou 'menu'.", + "expected_input_type": "text", + "transitions": {"default": "RESPOSTA_ANO_UMA_LOJA", "menu": "MENU_PRINCIPAL"} # Volta para pedir a loja para o Ano + }, + } # --- ESTADO INICIAL --- diff --git a/app/services/__pycache__/webhook_service.cpython-312.pyc b/app/services/__pycache__/webhook_service.cpython-312.pyc index 19bc549..7983f24 100644 Binary files a/app/services/__pycache__/webhook_service.cpython-312.pyc and b/app/services/__pycache__/webhook_service.cpython-312.pyc differ diff --git a/app/services/webhook_service.py b/app/services/webhook_service.py index 3de0858..dc945d5 100644 --- a/app/services/webhook_service.py +++ b/app/services/webhook_service.py @@ -3,6 +3,9 @@ from typing import List, Dict, Any, Optional import json import base64 import os +import pyodbc +import requests + @@ -20,11 +23,22 @@ scheduler = AsyncIOScheduler() # --- Variáveis Globais para Gerenciamento de Sessão (APENAS PARA TESTE) --- # Em produção, isso seria um banco de dados +ACTIVE_SESSIONS_FILE = "active_sessions.json" # Nome do arquivo de persistência ACTIVE_SESSIONS = {} # Dicionário para armazenar sessões ativas SESSION_TIMEOUT_SECONDS = 120 # 5 minutos (5 * 60 segundos) # --- FIM DAS VARIÁVEIS GLOBAIS DE SESSÃO --- - +# Em produção, esta lista viria de um banco de dados ou de um sistema de gerenciamento de usuários. +AUTHORIZED_NUMBERS = { + "558291655353", # João <-- SEU NÚMERO DE TELEFONE (com 55 DDD) + "558282309484", # Fernanda + "558298104313", # Efigenia + "557981017347", # Taciana + "558291202979", # Gabrielle + "557196046142" # Laiane Exemplo de outro número autorizado + # Adicione os números de telefone (apenas dígitos, no formato 55DDDxxxxxxxx) que você quer autorizar +} +UNAUTHORIZED_MESSAGE = "Desculpe, seu número não está cadastrado em nosso sistema. Por favor, entre em contato com nosso suporte para mais informações. Obrigado!" # Importações para criptografia from cryptography.hazmat.primitives.asymmetric import padding # <-- Esta linha está correta! @@ -34,7 +48,7 @@ from cryptography.hazmat.primitives.serialization import load_pem_private_key, l from cryptography.hazmat.backends import default_backend -from config import WHATSAPP_ACCESS_TOKEN, WHATSAPP_PHONE_NUMBER_ID, VERIFY_TOKEN, FLOW_PRIVATE_KEY_PASSWORD # <-- ADICIONADO +from config import WHATSAPP_ACCESS_TOKEN, WHATSAPP_PHONE_NUMBER_ID, VERIFY_TOKEN, FLOW_PRIVATE_KEY_PASSWORD, DATABASE_ODBC_CONN_STR # <-- ADICIONADO # Importe Message para type hinting. Certifique-se que webhook_model.py está correto primeiro! # Certifique-se de importar o grafo e o estado inicial from dialog_flow.graph_definition import DIALOG_GRAPH, INITIAL_STATE_ID @@ -204,59 +218,443 @@ def encrypt_flow_response_data(response_data: Dict[str, Any], aes_key: bytes, in return base64_encoded_final_response +# Persistência de Sessão: +# --- Funções de Persistência de Sessão --- +def load_sessions(): + """Carrega sessões ativas de um arquivo JSON. Chamada na inicialização do app.""" + global ACTIVE_SESSIONS + if os.path.exists(ACTIVE_SESSIONS_FILE): + try: + with open(ACTIVE_SESSIONS_FILE, 'r') as f: + data = json.load(f) + loaded_count = 0 + for sender_id, session_info in data.items(): + # Converte last_activity_time de string ISO para datetime + session_info['last_activity_time'] = datetime.fromisoformat(session_info['last_activity_time']) + ACTIVE_SESSIONS[sender_id] = session_info + loaded_count += 1 + print(f"DEBUG_SESSION_PERSIST: {loaded_count} sessões carregadas do arquivo {ACTIVE_SESSIONS_FILE}.") + except json.JSONDecodeError as e: + print(f"❌ ERRO_SESSION_PERSIST: Falha ao carregar sessões (JSON inválido): {e}") + except Exception as e: + print(f"❌ ERRO_SESSION_PERSIST: Erro inesperado ao carregar sessões: {e}") + else: + print(f"DEBUG_SESSION_PERSIST: Nenhum arquivo de sessão '{ACTIVE_SESSIONS_FILE}' encontrado para carregar.") +def save_sessions(): + """Salva sessões ativas em um arquivo JSON. Chamada após modificações e no desligamento do app.""" + data_to_save = {} + for sender_id, session_info in ACTIVE_SESSIONS.items(): + # Converte datetime para string ISO para serialização JSON + session_info_copy = session_info.copy() + session_info_copy['last_activity_time'] = session_info_copy['last_activity_time'].isoformat() + data_to_save[sender_id] = session_info_copy + try: + with open(ACTIVE_SESSIONS_FILE, 'w') as f: + json.dump(data_to_save, f, indent=4) + print(f"DEBUG_SESSION_PERSIST: {len(ACTIVE_SESSIONS)} sessões salvas no arquivo {ACTIVE_SESSIONS_FILE}.") + except Exception as e: + print(f"❌ ERRO_SESSION_PERSIST: Falha ao salvar sessões: {e}") -# --- Funções de Gerenciamento de Sessão --- - def get_session_state(sender_id: str) -> Optional[Dict[str, Any]]: + """ + Retorna o estado atual da sessão para um usuário. + """ return ACTIVE_SESSIONS.get(sender_id) -async def clean_session_and_notify(sender_id: str): # <-- FUNÇÃO ASSÍNCRONA PARA LIMPAR E NOTIFICAR +def start_or_update_session(sender_id: str, new_state: Optional[str] = None, last_message_id: Optional[str] = None): """ - Função assíncrona que limpa a sessão e envia a mensagem de timeout. - Chamada pelo scheduler. - """ - if sender_id in ACTIVE_SESSIONS: # Verifica se a sessão ainda está ativa (não foi atualizada antes do timeout) - timeout_message = "Sua sessão foi encerrada por inatividade. Por favor, envie uma nova mensagem para iniciar um novo atendimento. 😊" - print(f"DEBUG_SESSION: Enviando mensagem de timeout para {sender_id}.") - await send_text_message(sender_id, timeout_message) # <-- CHAMA A FUNÇÃO DE ENVIO - - del ACTIVE_SESSIONS[sender_id] - print(f"DEBUG_SESSION: Sessão para {sender_id} encerrada/limpa pelo agendador.") - else: - print(f"DEBUG_SESSION: Sessão para {sender_id} já limpa ou atualizada antes do agendamento.") - -def start_or_update_session(sender_id: str): - """ - Inicia uma nova sessão para o usuário ou atualiza o timestamp da última atividade. - Agenda ou reagenda a tarefa de limpeza. + Inicia uma nova sessão para o usuário ou atualiza o timestamp, estado e último ID de mensagem. """ current_time = datetime.now() - ACTIVE_SESSIONS[sender_id] = { - "last_activity_time": current_time, - "current_state": "INICIO" # Ou qualquer estado inicial padrão - } - print(f"DEBUG_SESSION: Sessão para {sender_id} iniciada/atualizada em {current_time.strftime('%Y-%m-%d %H:%M:%S')}.") + session_info = ACTIVE_SESSIONS.get(sender_id, {}) + + # Define o estado da sessão: + if new_state: + session_info["current_state"] = new_state + elif "current_state" not in session_info: # Apenas se for uma sessão nova (não tem estado ainda) + session_info["current_state"] = INITIAL_STATE_ID # Usar INITIAL_STATE_ID do graph_definition + + session_info["last_activity_time"] = current_time + if last_message_id: + session_info["last_processed_message_id"] = last_message_id + + ACTIVE_SESSIONS[sender_id] = session_info # Salva as informações atualizadas na memória + + save_sessions() # Persiste na memória para o arquivo + + print(f"DEBUG_SESSION: Sessão para {sender_id} iniciada/atualizada em {current_time.strftime('%Y-%m-%d %H:%M:%S')}. Estado: {session_info['current_state']}.") + if last_message_id: + print(f"DEBUG_SESSION: Último ID de mensagem para {sender_id}: {session_info.get('last_processed_message_id')}.") - # Remover tarefa agendada anterior para esta sessão, se houver job_id = f"session_clean_{sender_id}" if scheduler.get_job(job_id): scheduler.remove_job(job_id) print(f"DEBUG_SESSION: Tarefa de limpeza anterior para {sender_id} cancelada.") - # Agendar nova tarefa de limpeza para esta sessão scheduler.add_job( clean_session_and_notify, - 'date', # Agendamento único para uma data/hora específica + 'date', run_date=current_time + timedelta(seconds=SESSION_TIMEOUT_SECONDS), args=[sender_id], id=job_id, - replace_existing=True # Para garantir que não haja duplicatas, embora remove_job já ajude + replace_existing=True ) print(f"DEBUG_SESSION: Tarefa de limpeza para {sender_id} agendada para {current_time + timedelta(seconds=SESSION_TIMEOUT_SECONDS)}.") +async def clean_session_and_notify(sender_id: str): + """ + Função assíncrona que limpa a sessão e envia a mensagem de timeout. + Chamada pelo scheduler. + """ + # A função send_text_message deve estar disponível no webhook_service.py + # (ou importada, mas ela já deveria estar definida no seu código) + + if sender_id in ACTIVE_SESSIONS: + timeout_message = "Sua sessão foi encerrada por inatividade. Caso precise, inicie uma nova conversa." + print(f"DEBUG_SESSION: Enviando mensagem de timeout para {sender_id}.") + # Você deve ter a função send_text_message definida e funcional neste arquivo + # ou importada de outro módulo de serviço. + await send_text_message(sender_id, timeout_message) # <-- Assume que send_text_message está disponível + + del ACTIVE_SESSIONS[sender_id] + print(f"DEBUG_SESSION: Sessão para {sender_id} encerrada/limpa pelo agendador.") + save_sessions() # Persiste no arquivo após limpeza + else: + print(f"DEBUG_SESSION: Sessão para {sender_id} já limpa ou atualizada antes do agendamento.") + +async def saindo_da_sessao(sender_id: str): + """ + Função assíncrona que limpa a sessão e envia a mensagem de timeout. + Chamada pelo scheduler. + """ + # A função send_text_message deve estar disponível no webhook_service.py + # (ou importada, mas ela já deveria estar definida no seu código) + + if sender_id in ACTIVE_SESSIONS: + timeout_message = "Sua sessão foi encerrada por inatividade. Caso precise, inicie uma nova conversa." + print(f"DEBUG_SESSION: Enviando mensagem de timeout para {sender_id}.") + # Você deve ter a função send_text_message definida e funcional neste arquivo + # ou importada de outro módulo de serviço. + del ACTIVE_SESSIONS[sender_id] + print(f"DEBUG_SESSION: Sessão para {sender_id} encerrada/limpa pelo agendador.") + save_sessions() # Persiste no arquivo após limpeza + else: + print(f"DEBUG_SESSION: Sessão para {sender_id} já limpa ou atualizada antes do agendamento.") + +# Funções que fazem a consulta no Banco de Dados. +async def get_combined_indicator_data_ano_cp() -> Dict[str, Any]: + try: + conn = pyodbc.connect(DATABASE_ODBC_CONN_STR) + cursor = conn.cursor() + + # Comando SQL para selecionar o token + sql = "SELECT SUM([RECEITA (R$)]) AS receita_total, SUM([RECEITA (R$)]) / SUM([NUMERO DE BOLETOS]) AS boleto_medio FROM HUBSUPPLY.dbo.gmv_ano WHERE PDV != 'TOTAL';" + cursor.execute(sql) + + # Fetch o resultado + row = cursor.fetchone() + + if row: + receita = str(row[0]) + boleto = str(row[1]) + + except pyodbc.Error as ex: + sqlstate = ex.args[0] if ex.args else 'N/A' + # NOVO: Imprima o erro completo, incluindo a mensagem detalhada do pyodbc + print(f"❌ ERRO_DB: Erro de conexão ou execução no SQL Server.") + print(f"❌ ERRO_DB: SQLSTATE={sqlstate}") + print(f"❌ ERRO_DB: Mensagem detalhada: {ex.args[1] if len(ex.args) > 1 else 'N/A'}") + print(f"❌ ERRO_DB: Objeto de exceção completo: {ex}") # Isso mostrará o erro completo + return {} + + print(f"DEBUG_ACTION: Valores obtidos: CP={receita}, Boleto Médio={boleto}") + + return { + "receita": receita, + "boleto": boleto + } + +async def get_combined_indicator_data_ano_lojas(sender_id: str) -> Dict[str, Any]: + + supervisores_pdvs = { + "558298104313": "AND PDV in ('4560', '12522', '12823', '12826', '12828', '12829', '14617', '19103', '20969', '20991', '21647', '910173', '910291')", + "558282309484": "AND PDV in ('20005', '20006', '20009', '20056', '21068', '21375', '21381')", + "557981017347": "AND PDV in ('20441', '20968')", + "557196046142": "AND PDV in ('20006', '20056', '21068')", + "558291655353" : "AND PDV in ('20006', '20056', '21068')", + "558291202979" :"AND PDV in ('20441', '20968')" + } + + print(supervisores_pdvs[sender_id]) + + try: + conn = pyodbc.connect(DATABASE_ODBC_CONN_STR) + cursor = conn.cursor() + + # Comando SQL para selecionar o token + sql = f"SELECT SUM([RECEITA (R$)]) AS receita_total, SUM([RECEITA (R$)]) / SUM([NUMERO DE BOLETOS]) AS boleto_medio FROM HUBSUPPLY.dbo.gmv_ano WHERE PDV != 'TOTAL' {supervisores_pdvs[sender_id]};" + cursor.execute(sql) + + # Fetch o resultado + row = cursor.fetchone() + + if row: + receita = str(row[0]) + boleto = str(row[1]) + + except pyodbc.Error as ex: + sqlstate = ex.args[0] if ex.args else 'N/A' + # NOVO: Imprima o erro completo, incluindo a mensagem detalhada do pyodbc + print(f"❌ ERRO_DB: Erro de conexão ou execução no SQL Server.") + print(f"❌ ERRO_DB: SQLSTATE={sqlstate}") + print(f"❌ ERRO_DB: Mensagem detalhada: {ex.args[1] if len(ex.args) > 1 else 'N/A'}") + print(f"❌ ERRO_DB: Objeto de exceção completo: {ex}") # Isso mostrará o erro completo + return {} + + print(f"DEBUG_ACTION: Valores obtidos: CP={receita}, Boleto Médio={boleto}") + + return { + "receita": receita, + "boleto": boleto + } + +async def get_combined_indicator_data_mes_cp() -> Dict[str, Any]: + try: + conn = pyodbc.connect(DATABASE_ODBC_CONN_STR) + cursor = conn.cursor() + + # Comando SQL para selecionar o token + sql = "SELECT SUM([RECEITA (R$)]) AS receita_total, SUM([RECEITA (R$)]) / SUM([NUMERO DE BOLETOS]) AS boleto_medio FROM HUBSUPPLY.dbo.gmv_mês WHERE PDV != 'TOTAL';" + cursor.execute(sql) + + # Fetch o resultado + row = cursor.fetchone() + + if row: + receita = str(row[0]) + boleto = str(row[1]) + + except pyodbc.Error as ex: + sqlstate = ex.args[0] if ex.args else 'N/A' + # NOVO: Imprima o erro completo, incluindo a mensagem detalhada do pyodbc + print(f"❌ ERRO_DB: Erro de conexão ou execução no SQL Server.") + print(f"❌ ERRO_DB: SQLSTATE={sqlstate}") + print(f"❌ ERRO_DB: Mensagem detalhada: {ex.args[1] if len(ex.args) > 1 else 'N/A'}") + print(f"❌ ERRO_DB: Objeto de exceção completo: {ex}") # Isso mostrará o erro completo + return {} + + print(f"DEBUG_ACTION: Valores obtidos: CP={receita}, Boleto Médio={boleto}") + + return { + "receita": receita, + "boleto": boleto + } + +async def get_combined_indicator_data_mes_lojas(sender_id: str) -> Dict[str, Any]: + + supervisores_pdvs = { + "558298104313": "AND PDV in ('4560', '12522', '12823', '12826', '12828', '12829', '14617', '19103', '20969', '20991', '21647', '910173', '910291')", + "558282309484": "AND PDV in ('20005', '20006', '20009', '20056', '21068', '21375', '21381')", + "557981017347": "AND PDV in ('20441', '20968')", + "557196046142": "AND PDV in ('20006', '20056', '21068')", + "558291655353" : "AND PDV in ('20006', '20056', '21068')", + "558291202979" :"AND PDV in ('20441', '20968')" + } + + print(supervisores_pdvs[sender_id]) + + + + try: + conn = pyodbc.connect(DATABASE_ODBC_CONN_STR) + cursor = conn.cursor() + + # Comando SQL para selecionar o token + sql = f"SELECT SUM([RECEITA (R$)]) AS receita_total, SUM([RECEITA (R$)]) / SUM([NUMERO DE BOLETOS]) AS boleto_medio FROM HUBSUPPLY.dbo.gmv_mês WHERE PDV != 'TOTAL' {supervisores_pdvs[sender_id]};" + cursor.execute(sql) + + # Fetch o resultado + row = cursor.fetchone() + + if row: + receita = str(row[0]) + boleto = str(row[1]) + + except pyodbc.Error as ex: + sqlstate = ex.args[0] if ex.args else 'N/A' + # NOVO: Imprima o erro completo, incluindo a mensagem detalhada do pyodbc + print(f"❌ ERRO_DB: Erro de conexão ou execução no SQL Server.") + print(f"❌ ERRO_DB: SQLSTATE={sqlstate}") + print(f"❌ ERRO_DB: Mensagem detalhada: {ex.args[1] if len(ex.args) > 1 else 'N/A'}") + print(f"❌ ERRO_DB: Objeto de exceção completo: {ex}") # Isso mostrará o erro completo + return {} + + print(f"DEBUG_ACTION: Valores obtidos: CP={receita}, Boleto Médio={boleto}") + + return { + "receita": receita, + "boleto": boleto + } + + +async def get_combined_indicator_data_ontem_cp() -> Dict[str, Any]: + + + + try: + conn = pyodbc.connect(DATABASE_ODBC_CONN_STR) + cursor = conn.cursor() + + # Comando SQL para selecionar o token + sql = "SELECT SUM([RECEITA (R$)]) AS receita_total, SUM([RECEITA (R$)]) / SUM([NUMERO DE BOLETOS]) AS boleto_medio FROM HUBSUPPLY.dbo.gmv_ontem WHERE PDV != 'TOTAL';" + cursor.execute(sql) + + # Fetch o resultado + row = cursor.fetchone() + + if row: + receita = str(row[0]) + boleto = str(row[1]) + + except pyodbc.Error as ex: + sqlstate = ex.args[0] if ex.args else 'N/A' + # NOVO: Imprima o erro completo, incluindo a mensagem detalhada do pyodbc + print(f"❌ ERRO_DB: Erro de conexão ou execução no SQL Server.") + print(f"❌ ERRO_DB: SQLSTATE={sqlstate}") + print(f"❌ ERRO_DB: Mensagem detalhada: {ex.args[1] if len(ex.args) > 1 else 'N/A'}") + print(f"❌ ERRO_DB: Objeto de exceção completo: {ex}") # Isso mostrará o erro completo + return {} + + print(f"DEBUG_ACTION: Valores obtidos: CP={receita}, Boleto Médio={boleto}") + + return { + "receita": receita, + "boleto": boleto + } + +async def get_combined_indicator_data_ontem_lojas(sender_id: str) -> Dict[str, Any]: + + supervisores_pdvs = { + "558298104313": "AND PDV in ('4560', '12522', '12823', '12826', '12828', '12829', '14617', '19103', '20969', '20991', '21647', '910173', '910291')", + "558282309484": "AND PDV in ('20005', '20006', '20009', '20056', '21068', '21375', '21381')", + "557981017347": "AND PDV in ('20441', '20968')", + "557196046142": "AND PDV in ('20006', '20056', '21068')", + "558291655353" : "AND PDV in ('20006', '20056', '21068')", + "558291202979" :"AND PDV in ('20441', '20968')" + } + + print(supervisores_pdvs[sender_id]) + + try: + conn = pyodbc.connect(DATABASE_ODBC_CONN_STR) + cursor = conn.cursor() + + # Comando SQL para selecionar o token + sql = f"SELECT SUM([RECEITA (R$)]) AS receita_total, SUM([RECEITA (R$)]) / SUM([NUMERO DE BOLETOS]) AS boleto_medio FROM HUBSUPPLY.dbo.gmv_ontem WHERE PDV != 'TOTAL' {supervisores_pdvs[sender_id]};" + cursor.execute(sql) + + # Fetch o resultado + row = cursor.fetchone() + + if row: + receita = str(row[0]) + boleto = str(row[1]) + + except pyodbc.Error as ex: + sqlstate = ex.args[0] if ex.args else 'N/A' + # NOVO: Imprima o erro completo, incluindo a mensagem detalhada do pyodbc + print(f"❌ ERRO_DB: Erro de conexão ou execução no SQL Server.") + print(f"❌ ERRO_DB: SQLSTATE={sqlstate}") + print(f"❌ ERRO_DB: Mensagem detalhada: {ex.args[1] if len(ex.args) > 1 else 'N/A'}") + print(f"❌ ERRO_DB: Objeto de exceção completo: {ex}") # Isso mostrará o erro completo + return {} + + print(f"DEBUG_ACTION: Valores obtidos: CP={receita}, Boleto Médio={boleto}") + + return { + "receita": receita, + "boleto": boleto + } + +async def get_combined_indicator_data_hoje_cp() -> Dict[str, Any]: + try: + conn = pyodbc.connect(DATABASE_ODBC_CONN_STR) + cursor = conn.cursor() + + # Comando SQL para selecionar o token + sql = "SELECT SUM([RECEITA (R$)]) AS receita_total, SUM([RECEITA (R$)]) / SUM([NUMERO DE BOLETOS]) AS boleto_medio FROM HUBSUPPLY.dbo.gmv_hoje WHERE PDV != 'TOTAL';" + cursor.execute(sql) + + # Fetch o resultado + row = cursor.fetchone() + + if row: + receita = str(row[0]) + boleto = str(row[1]) + + except pyodbc.Error as ex: + sqlstate = ex.args[0] if ex.args else 'N/A' + # NOVO: Imprima o erro completo, incluindo a mensagem detalhada do pyodbc + print(f"❌ ERRO_DB: Erro de conexão ou execução no SQL Server.") + print(f"❌ ERRO_DB: SQLSTATE={sqlstate}") + print(f"❌ ERRO_DB: Mensagem detalhada: {ex.args[1] if len(ex.args) > 1 else 'N/A'}") + print(f"❌ ERRO_DB: Objeto de exceção completo: {ex}") # Isso mostrará o erro completo + return {} + + print(f"DEBUG_ACTION: Valores obtidos: CP={receita}, Boleto Médio={boleto}") + + return { + "receita": receita, + "boleto": boleto + } + +async def get_combined_indicator_data_hoje_lojas(sender_id: str) -> Dict[str, Any]: + + supervisores_pdvs = { + "558298104313": "AND PDV in ('4560', '12522', '12823', '12826', '12828', '12829', '14617', '19103', '20969', '20991', '21647', '910173', '910291')", + "558282309484": "AND PDV in ('20005', '20006', '20009', '20056', '21068', '21375', '21381')", + "557981017347": "AND PDV in ('20441', '20968')", + "557196046142": "AND PDV in ('20006', '20056', '21068')", + "558291655353" : "AND PDV in ('20006', '20056', '21068')", + "558291202979" :"AND PDV in ('20441', '20968')" + } + + print(supervisores_pdvs[sender_id]) + + + try: + conn = pyodbc.connect(DATABASE_ODBC_CONN_STR) + cursor = conn.cursor() + + # Comando SQL para selecionar o token + sql = f"SELECT SUM([RECEITA (R$)]) AS receita_total, SUM([RECEITA (R$)]) / SUM([NUMERO DE BOLETOS]) AS boleto_medio FROM HUBSUPPLY.dbo.gmv_hoje WHERE PDV != 'TOTAL' {supervisores_pdvs[sender_id]};" + cursor.execute(sql) + + # Fetch o resultado + row = cursor.fetchone() + + if row: + receita = str(row[0]) + boleto = str(row[1]) + + except pyodbc.Error as ex: + sqlstate = ex.args[0] if ex.args else 'N/A' + # NOVO: Imprima o erro completo, incluindo a mensagem detalhada do pyodbc + print(f"❌ ERRO_DB: Erro de conexão ou execução no SQL Server.") + print(f"❌ ERRO_DB: SQLSTATE={sqlstate}") + print(f"❌ ERRO_DB: Mensagem detalhada: {ex.args[1] if len(ex.args) > 1 else 'N/A'}") + print(f"❌ ERRO_DB: Objeto de exceção completo: {ex}") # Isso mostrará o erro completo + return {} + + print(f"DEBUG_ACTION: Valores obtidos: CP={receita}, Boleto Médio={boleto}") + + return { + "receita": receita, + "boleto": boleto + } #Fluxo de mensagens: @@ -302,6 +700,10 @@ async def process_user_input_with_graph(sender_id: str, message_type: str, messa # O message_content já é o payload do botão next_state_id = current_state.get("transitions", {}).get(message_content, INITIAL_STATE_ID) + elif message_type == "list_reply": + # O message_content já é o payload do botão + next_state_id = current_state.get("transitions", {}).get(message_content, INITIAL_STATE_ID) + elif message_type == "flow_nfm_reply": # Quando uma resposta de Flow chega, o next_state depende do sucesso/falha do Flow # Assumimos que handle_flow_response já processou o Flow e determinou sucesso/falha @@ -318,7 +720,34 @@ async def process_user_input_with_graph(sender_id: str, message_type: str, messa print(f"DEBUG_GRAPH: Usuário {sender_id} transicionou para o estado: '{next_state_id}'") await execute_state_action_and_respond(sender_id, next_state_id, session_data) + +def format_currency_brl(value: Any) -> str: # <-- Mude o tipo para Any para ser mais flexível na entrada + """ + Formata um valor para o formato de moeda BRL (R$ 0.000,00), + garantindo a conversão para float primeiro. + """ + if value is None: + return "N/A" + + # Tenta converter o valor para float, capturando possíveis erros + try: + float_value = float(value) + except (ValueError, TypeError): + print(f"❌ ERRO_FORMAT: Valor '{value}' não pode ser convertido para float para formatação de moeda. Retornando como string.") + return str(value) # Se não puder converter, retorna o valor original como string + # Formatação original (robusta para floats) + formatted_value = f"{float_value:,.2f}" + + if '.' in formatted_value and ',' in formatted_value: + parts = formatted_value.split('.') + integer_part_with_commas = parts[0] + decimal_part = parts[1] + formatted_value = integer_part_with_commas.replace(',', '.') + ',' + decimal_part + elif '.' in formatted_value: + formatted_value = formatted_value.replace('.', ',') + + return f"R$ {formatted_value}" async def execute_state_action_and_respond(sender_id: str, state_id: str, session_data: Dict[str, Any]): """ @@ -341,6 +770,159 @@ async def execute_state_action_and_respond(sender_id: str, state_id: str, sessio await send_time_menu(sender_id) elif action == "send_main_store": await send_store_menu(sender_id) + elif action == "get_combined_indicator_data_ano_cp": # <-- NOVA AÇÃO AGORA + db_results = await get_combined_indicator_data_ano_cp() + + if db_results: + # Armazene cada valor retornado em chaves separadas da sessão + session_data["receita"] = format_currency_brl(db_results.get('receita', 0.0)) + session_data["boleto"] = format_currency_brl(db_results.get('boleto', 0.0)) # Aplica a formatação também para boleto + + print(f"DEBUG_GRAPH: Valores CP='{session_data["receita"]}' e Boleto_Medio='{session_data["boleto"]}' salvos na sessão.") + start_or_update_session(sender_id, new_state=state_id, last_message_id=session_data.get("last_processed_message_id")) # Atualiza sessão com os dados + else: + session_data["receita"] = "indisponível" + session_data["boleto"] = "indisponível" + print("DEBUG_GRAPH: Falha ao obter Total do CP e Lojas Ativas.") + await send_text_message(sender_id, "Não foi possível obter os indicadores no momento. Por favor, tente mais tarde.") + # Opcional: Transicionar para um estado de erro ou para o menu principal + session_data["current_state"] = DIALOG_GRAPH.get("MENU_PRINCIPAL", {}).get("transitions", {}).get("default", INITIAL_STATE_ID) + start_or_update_session(sender_id, new_state=session_data["current_state"], last_message_id=session_data.get("last_processed_message_id")) + return # Interrompe a execução para não enviar a mensagem do estado normal + + elif action == "get_combined_indicator_data_ano_lojas": # <-- NOVA AÇÃO AGORA + db_results = await get_combined_indicator_data_ano_lojas(sender_id) + if db_results: + # Armazene cada valor retornado em chaves separadas da sessão + session_data["receita"] = format_currency_brl(db_results.get('receita', 0.0)) + session_data["boleto"] = format_currency_brl(db_results.get('boleto', 0.0)) # Aplica a formatação também para boleto + + print(f"DEBUG_GRAPH: Valores CP='{session_data["receita"]}' e Boleto_Medio='{session_data["boleto"]}' salvos na sessão.") + start_or_update_session(sender_id, new_state=state_id, last_message_id=session_data.get("last_processed_message_id")) # Atualiza sessão com os dados + else: + session_data["receita"] = "indisponível" + session_data["boleto"] = "indisponível" + print("DEBUG_GRAPH: Falha ao obter Total do CP e Lojas Ativas.") + await send_text_message(sender_id, "Não foi possível obter os indicadores no momento. Por favor, tente mais tarde.") + # Opcional: Transicionar para um estado de erro ou para o menu principal + session_data["current_state"] = DIALOG_GRAPH.get("MENU_PRINCIPAL", {}).get("transitions", {}).get("default", INITIAL_STATE_ID) + start_or_update_session(sender_id, new_state=session_data["current_state"], last_message_id=session_data.get("last_processed_message_id")) + return # Interrompe a execução para não enviar a mensagem do estado normal + + elif action == "get_combined_indicator_data_mes_cp": # <-- NOVA AÇÃO AGORA + db_results = await get_combined_indicator_data_mes_cp() + if db_results: + # Armazene cada valor retornado em chaves separadas da sessão + session_data["receita"] = format_currency_brl(db_results.get('receita', 0.0)) + session_data["boleto"] = format_currency_brl(db_results.get('boleto', 0.0)) # Aplica a formatação também para boleto + + print(f"DEBUG_GRAPH: Valores CP='{session_data["receita"]}' e Boleto_Medio='{session_data["boleto"]}' salvos na sessão.") + start_or_update_session(sender_id, new_state=state_id, last_message_id=session_data.get("last_processed_message_id")) # Atualiza sessão com os dados + else: + session_data["receita"] = "indisponível" + session_data["boleto"] = "indisponível" + print("DEBUG_GRAPH: Falha ao obter Total do CP e Lojas Ativas.") + await send_text_message(sender_id, "Não foi possível obter os indicadores no momento. Por favor, tente mais tarde.") + # Opcional: Transicionar para um estado de erro ou para o menu principal + session_data["current_state"] = DIALOG_GRAPH.get("MENU_PRINCIPAL", {}).get("transitions", {}).get("default", INITIAL_STATE_ID) + start_or_update_session(sender_id, new_state=session_data["current_state"], last_message_id=session_data.get("last_processed_message_id")) + return # Interrompe a execução para não enviar a mensagem do estado normal + + elif action == "get_combined_indicator_data_mes_lojas": # <-- NOVA AÇÃO AGORA + db_results = await get_combined_indicator_data_mes_lojas(sender_id) + if db_results: + # Armazene cada valor retornado em chaves separadas da sessão + session_data["receita"] = format_currency_brl(db_results.get('receita', 0.0)) + session_data["boleto"] = format_currency_brl(db_results.get('boleto', 0.0)) # Aplica a formatação também para boleto + + print(f"DEBUG_GRAPH: Valores CP='{session_data["receita"]}' e Boleto_Medio='{session_data["boleto"]}' salvos na sessão.") + start_or_update_session(sender_id, new_state=state_id, last_message_id=session_data.get("last_processed_message_id")) # Atualiza sessão com os dados + else: + session_data["receita"] = "indisponível" + session_data["boleto"] = "indisponível" + print("DEBUG_GRAPH: Falha ao obter Total do CP e Lojas Ativas.") + await send_text_message(sender_id, "Não foi possível obter os indicadores no momento. Por favor, tente mais tarde.") + # Opcional: Transicionar para um estado de erro ou para o menu principal + session_data["current_state"] = DIALOG_GRAPH.get("MENU_PRINCIPAL", {}).get("transitions", {}).get("default", INITIAL_STATE_ID) + start_or_update_session(sender_id, new_state=session_data["current_state"], last_message_id=session_data.get("last_processed_message_id")) + return # Interrompe a execução para não enviar a mensagem do estado normal + + elif action == "get_combined_indicator_data_ontem_cp": # <-- NOVA AÇÃO AGORA + db_results = await get_combined_indicator_data_ontem_cp() + if db_results: + # Armazene cada valor retornado em chaves separadas da sessão + session_data["receita"] = format_currency_brl(db_results.get('receita', 0.0)) + session_data["boleto"] = format_currency_brl(db_results.get('boleto', 0.0)) # Aplica a formatação também para boleto + + print(f"DEBUG_GRAPH: Valores CP='{session_data["receita"]}' e Boleto_Medio='{session_data["boleto"]}' salvos na sessão.") + start_or_update_session(sender_id, new_state=state_id, last_message_id=session_data.get("last_processed_message_id")) # Atualiza sessão com os dados + else: + session_data["receita"] = "indisponível" + session_data["boleto"] = "indisponível" + print("DEBUG_GRAPH: Falha ao obter Total do CP e Lojas Ativas.") + await send_text_message(sender_id, "Não foi possível obter os indicadores no momento. Por favor, tente mais tarde.") + # Opcional: Transicionar para um estado de erro ou para o menu principal + session_data["current_state"] = DIALOG_GRAPH.get("MENU_PRINCIPAL", {}).get("transitions", {}).get("default", INITIAL_STATE_ID) + start_or_update_session(sender_id, new_state=session_data["current_state"], last_message_id=session_data.get("last_processed_message_id")) + return # Interrompe a execução para não enviar a mensagem do estado normal + + elif action == "get_combined_indicator_data_ontem_lojas": # <-- NOVA AÇÃO AGORA + db_results = await get_combined_indicator_data_ontem_lojas() + if db_results: + # Armazene cada valor retornado em chaves separadas da sessão + session_data["receita"] = format_currency_brl(db_results.get('receita', 0.0)) + session_data["boleto"] = format_currency_brl(db_results.get('boleto', 0.0)) # Aplica a formatação também para boleto + + print(f"DEBUG_GRAPH: Valores CP='{session_data["receita"]}' e Boleto_Medio='{session_data["boleto"]}' salvos na sessão.") + start_or_update_session(sender_id, new_state=state_id, last_message_id=session_data.get("last_processed_message_id")) # Atualiza sessão com os dados + else: + session_data["receita"] = "indisponível" + session_data["boleto"] = "indisponível" + print("DEBUG_GRAPH: Falha ao obter Total do CP e Lojas Ativas.") + await send_text_message(sender_id, "Não foi possível obter os indicadores no momento. Por favor, tente mais tarde.") + # Opcional: Transicionar para um estado de erro ou para o menu principal + session_data["current_state"] = DIALOG_GRAPH.get("MENU_PRINCIPAL", {}).get("transitions", {}).get("default", INITIAL_STATE_ID) + start_or_update_session(sender_id, new_state=session_data["current_state"], last_message_id=session_data.get("last_processed_message_id")) + return # Interrompe a execução para não enviar a mensagem do estado normal + + elif action == "get_combined_indicator_data_hoje_cp": # <-- NOVA AÇÃO AGORA + db_results = await get_combined_indicator_data_hoje_cp() + if db_results: + # Armazene cada valor retornado em chaves separadas da sessão + session_data["receita"] = format_currency_brl(db_results.get('receita', 0.0)) + session_data["boleto"] = format_currency_brl(db_results.get('boleto', 0.0)) # Aplica a formatação também para boleto + + print(f"DEBUG_GRAPH: Valores CP='{session_data["receita"]}' e Boleto_Medio='{session_data["boleto"]}' salvos na sessão.") + start_or_update_session(sender_id, new_state=state_id, last_message_id=session_data.get("last_processed_message_id")) # Atualiza sessão com os dados + else: + session_data["receita"] = "indisponível" + session_data["boleto"] = "indisponível" + print("DEBUG_GRAPH: Falha ao obter Total do CP e Lojas Ativas.") + await send_text_message(sender_id, "Não foi possível obter os indicadores no momento. Por favor, tente mais tarde.") + # Opcional: Transicionar para um estado de erro ou para o menu principal + session_data["current_state"] = DIALOG_GRAPH.get("MENU_PRINCIPAL", {}).get("transitions", {}).get("default", INITIAL_STATE_ID) + start_or_update_session(sender_id, new_state=session_data["current_state"], last_message_id=session_data.get("last_processed_message_id")) + return # Interrompe a execução para não enviar a mensagem do estado normal + + elif action == "get_combined_indicator_data_hoje_lojas": # <-- NOVA AÇÃO AGORA + db_results = await get_combined_indicator_data_hoje_lojas() + if db_results: + # Armazene cada valor retornado em chaves separadas da sessão + session_data["receita"] = format_currency_brl(db_results.get('receita', 0.0)) + session_data["boleto"] = format_currency_brl(db_results.get('boleto', 0.0)) # Aplica a formatação também para boleto + + print(f"DEBUG_GRAPH: Valores CP='{session_data["receita"]}' e Boleto_Medio='{session_data["boleto"]}' salvos na sessão.") + start_or_update_session(sender_id, new_state=state_id, last_message_id=session_data.get("last_processed_message_id")) # Atualiza sessão com os dados + else: + session_data["receita"] = "indisponível" + session_data["boleto"] = "indisponível" + print("DEBUG_GRAPH: Falha ao obter Total do CP e Lojas Ativas.") + await send_text_message(sender_id, "Não foi possível obter os indicadores no momento. Por favor, tente mais tarde.") + # Opcional: Transicionar para um estado de erro ou para o menu principal + session_data["current_state"] = DIALOG_GRAPH.get("MENU_PRINCIPAL", {}).get("transitions", {}).get("default", INITIAL_STATE_ID) + start_or_update_session(sender_id, new_state=session_data["current_state"], last_message_id=session_data.get("last_processed_message_id")) + return # Interrompe a execução para não enviar a mensagem do estado normal + elif action == "send_flow_cadastro": flow_id = state_definition.get("flow_id") flow_cta = state_definition.get("flow_cta") @@ -371,34 +953,7 @@ async def execute_state_action_and_respond(sender_id: str, state_id: str, sessio start_or_update_session(sender_id) - elif action == "save_temp_service": - # Exemplo: guardar o serviço agendado - # service = session_data.get("last_text_input") # Pega o último texto que o usuário enviou - # session_data["servico_agendado"] = service - # start_or_update_session(sender_id) # Atualiza a sessão - pass # Ação interna, não envia mensagem aqui - - elif action == "confirm_appointment": - # Exemplo: confirmar agendamento no sistema externo - # (Chamada de API para sistema de agendamento) - pass # Ação interna, a mensagem já vem do state_definition - - - elif action == "call_external_status_api": - # AQUI é onde você faria a chamada para o seu sistema externo - # Por enquanto, apenas um placeholder - # numero_pedido = session_data.get("last_text_input") - # print(f"DEBUG_GRAPH: Chamando API externa para pedido {numero_pedido}") - # try: - # status_api = await external_api_call(numero_pedido) - # session_data["status_retornado"] = status_api # Salva na sessão - # # Transicionar internamente para STATUS_EXIBIR ou STATUS_NAO_ENCONTRADO - # await execute_state_action_and_respond(sender_id, "STATUS_EXIBIR", session_data) # Transição interna - # return - # except Exception: - # await execute_state_action_and_respond(sender_id, "STATUS_NAO_ENCONTRADO", session_data) # Transição interna - # return - pass # Implementação futura, a mensagem vem do estado. + # --- Enviar a mensagem do novo estado (se houver) --- if "message" in state_definition: @@ -410,6 +965,11 @@ async def execute_state_action_and_respond(sender_id: str, state_id: str, sessio if "${email}" in final_message and session_data.get("flow_data"): flow_data = json.loads(session_data["flow_data"]) final_message = final_message.replace("${email}", flow_data.get("email", "não informado")) + if "${receita}" in final_message: + final_message = final_message.replace("${receita}", str(session_data.get("receita", "N/A"))) + if "${boleto}" in final_message: + final_message = final_message.replace("${boleto}", str(session_data.get("boleto", "N/A"))) + # Para outros placeholders como ${servico_agendado}, ${data_horario_agendado}, ${numero_pedido}, ${status_retornado} # você faria substituições semelhantes baseadas em session_data @@ -425,18 +985,6 @@ async def execute_state_action_and_respond(sender_id: str, state_id: str, sessio - - - - - - - - - - - - # --- Lógicas de Tratamento de Mensagens Recebidas (Funções Auxiliares) --- # Estas funções contêm a lógica de como seu bot irá interagir. @@ -444,66 +992,85 @@ async def execute_state_action_and_respond(sender_id: str, state_id: str, sessio #--- FUNÇÃO handle_message_type: A VERSÃO CORRETA PARA O TIMEOUT E GRAFO --- async def handle_message_type(message: Message): sender_id = message.from_ + message_id = message.id # O ID da mensagem recebida - # 1. Limpar sessões inativas - # Esta função limpa as sessões que expiraram e envia a mensagem de timeout via scheduler. - start_or_update_session(sender_id) # Esta é a que precisa ser assíncrona + # 1. ANTI-DUPLICAÇÃO DE MENSAGENS: + # Obtém a sessão ATUAL (se existir) para verificar o último ID processado. + session_data_for_check = get_session_state(sender_id) + if session_data_for_check and session_data_for_check.get("last_processed_message_id") == message_id: + print(f"⚠️ Mensagem duplicada recebida para {sender_id} (ID: {message_id}). Ignorando.") + # Retorna imediatamente para evitar reprocessamento. + # Nenhuma sessão é atualizada, nem agendamento refeito. + return - # 2. INICIAR OU ATUALIZAR A SESSÃO PARA O REMETENTE ATUAL. - # ISSO É ESSENCIAL PARA O TIMEOUT. Se o usuário mandar mensagem, a sessão dele é atualizada - # e o agendamento de timeout é resetado. - start_or_update_session(sender_id) + # 2. VERIFICAR WHITELIST DE NÚMEROS (SEGUNDO) + if sender_id not in AUTHORIZED_NUMBERS: + print(f"INFO_BOT_CONTROL: Número {sender_id} NÃO autorizado. Enviando mensagem de não cadastrado.") + await send_text_message(sender_id, UNAUTHORIZED_MESSAGE) + return # Não processa mais nada se o número não estiver na whitelist + # NENHUMA CHAMADA explícita para clean_inactive_sessions() AQUI. + # A limpeza de sessões inativas já é tratada pelo scheduler em background. - # 3. Recuperar o estado da sessão (já atualizado) - session_data = get_session_state(sender_id) - if session_data: - print(f"DEBUG_SESSION: Estado atual da sessão para {sender_id}: {session_data['current_state']}") - else: - # Isso não deveria acontecer se start_or_update_session funcionou - print(f"DEBUG_SESSION: ERRO: Sessão para {sender_id} não encontrada após atualização. Isso é inesperado.") - # Se por algum motivo não tiver sessão, podemos resetar para o estado inicial - start_or_update_session(sender_id) - session_data = get_session_state(sender_id) # Tenta novamente + # 3. INICIAR OU ATUALIZAR A SESSÃO, INCLUINDO O ID DA MENSAGEM ATUAL: + # Isso atualizará o last_activity_time, agendará o timeout, e salvará o novo message_id. + start_or_update_session(sender_id, last_message_id=message_id) + + # Recarrega session_data para garantir que 'last_processed_message_id' esteja lá e para + # obter o 'current_state' mais recente que start_or_update_session pode ter inicializado. + session_data = get_session_state(sender_id) + + # Esta verificação agora é mais um fallback, pois start_or_update_session deve garantir a existência. + if not session_data: + print(f"DEBUG_SESSION: ERRO GRAVE: Sessão para {sender_id} ainda não encontrada após start_or_update_session. Isso é crítico.") + # Tenta uma recuperação de emergência, mas indica um problema + start_or_update_session(sender_id, new_state=INITIAL_STATE_ID, last_message_id=message_id) + session_data = get_session_state(sender_id) + if not session_data: # Se ainda assim falhar, algo está muito errado + print("❌ ERRO FATAL: Falha crítica na gestão de sessão. Não é possível processar a mensagem.") + return # Não é seguro continuar + + + print(f"DEBUG_SESSION: Estado atual da sessão para {sender_id}: {session_data['current_state']}") + print(f"DEBUG_SESSION: ID da mensagem atual processada: {message_id}") message_content = None message_type = None - # --- IDENTIFICAÇÃO DO TIPO DE MENSAGEM --- - # Esta parte permanece como estava, identificando o tipo e conteúdo. + # --- IDENTIFICAÇÃO DO TIPO DE MENSAGEM (permanece a mesma) --- if message.type == 'text' and message.text: message_type = "text" message_content = message.text.body - elif message.type == 'button' and message.button: # Resposta de botão de resposta rápida + elif message.type == 'button' and message.button: message_type = "button_click" message_content = message.button.payload elif message.type == 'interactive' and message.interactive: - if message.interactive.type == 'list_reply' and message.interactive.list_reply: # Resposta de lista + if message.interactive.type == 'list_reply' and message.interactive.list_reply: message_type = "list_reply" message_content = message.interactive.list_reply.id - elif message.interactive.type == 'button_reply' and message.interactive.button_reply: # Resposta de botão interativo - message_type = "button_click" # Tratar como clique de botão + elif message.interactive.type == 'button_reply' and message.interactive.button_reply: + message_type = "button_click" message_content = message.interactive.button_reply.id - elif message.interactive.type == 'nfm_reply' and message.interactive.nfm_reply: # Resposta de Flow + elif message.interactive.type == 'nfm_reply' and message.interactive.nfm_reply: message_type = "flow_nfm_reply" message_content = message.interactive.nfm_reply.response_json else: print(f" Tipo interativo desconhecido recebido: {message.interactive.type}") await send_text_message(sender_id, "Desculpe, não entendi essa interação interativa.") - return # Sai, não há transição no grafo para isso - elif message.type == 'image': # Mensagens que não são processadas pelo grafo + return + elif message.type == 'image': message_type = "image" - message_content = "imagem_recebida" # Placeholder + message_content = "imagem_recebida" await send_text_message(sender_id, "Recebi sua imagem. No momento, só consigo processar texto e interações.") - return # Sai, não há transição no grafo para isso - else: # Tipo de mensagem não suportado ou desconhecido + return + else: message_type = "unsupported" message_content = "tipo_desconhecido" await send_text_message(sender_id, "Desculpe, não entendi o tipo de mensagem que você enviou.") - return # Sai + return - # 4. O motor do grafo processa a entrada e gerencia o estado da sessão. - # A `session_data` já foi atualizada no passo 2. + # 3. O motor do grafo processa a entrada e gerencia o estado da sessão. + # A `session_data` já está atualizada com o último ID da mensagem. await process_user_input_with_graph(sender_id, message_type, message_content) @@ -627,7 +1194,7 @@ async def send_time_menu(to: str): # <-- FUNÇÃO QUE VOCÊ PEDIU """ Envia uma mensagem interativa de LISTA para o usuário com opções de lojas. """ - header_text = "Escolha o Período que você quer visualizar o indicador" + header_text = "Escolha o Período que você deseja visualizar:" body_text = "Veja as opções a baixo" button_title = "Clique aqui" # Texto do botão que o usuário clica para ABRIR a lista @@ -663,23 +1230,22 @@ async def send_store_menu(to: str): # <-- FUNÇÃO QUE VOCÊ PEDIU """ Envia uma mensagem interativa de LISTA para o usuário com opções de lojas. """ - header_text = "Escolha o Período que você quer visualizar o indicador" + header_text = "Escolha a Dimensão das Lojas que você deseja visualizar:" body_text = "Veja as opções a baixo" button_title = "Clique aqui" # Texto do botão que o usuário clica para ABRIR a lista sections = [ { - "title": "Acumulados", # Título da primeira seção + "title": "Acumulado", # Título da primeira seção "rows": [ - {"id": 'OPTION_ANO', "title": 'Total do CP', "description": "Visualize o resultado Total do CP"}, - {"id": 'OPTION_MES', "title": 'Total do Estado', "description": "Visualize o resultado Total das suas Lojas"}, + {"id": 'OPTION_TOTAL_CP', "title": 'Total do CP', "description": "Visualize o resultado Total do CP"} ] }, { "title": "Por Loja", # Título da segunda seção "rows": [ - {"id": 'OPTION_ONTEM', "title": 'Total das suas Lojas', "description": "Visualize o total das suas Lojas"}, - {"id": 'OPTION_HOJE', "title": 'Total de uma Loja', "description": "Visualize o total de uma loja."} + {"id": 'OPTION_TOTAL_LOJAS', "title": 'Total das suas Lojas', "description": "Visualize o total das suas Lojas"}, + {"id": 'OPTION_TOTAL_UMA_LOJA', "title": 'Total de uma Loja', "description": "Visualize o total de uma loja."} ] }, {