From 897b3b11ac33c07372e855083805e1f1105c8f32 Mon Sep 17 00:00:00 2001 From: Joao Monezi Date: Mon, 23 Jun 2025 07:03:40 +0000 Subject: [PATCH] Comitando flow_respose_without_one_store --- app/__pycache__/config.cpython-312.pyc | Bin 870 -> 1049 bytes app/active_sessions.json | 1 + app/config.py | 3 + app/dialog_flow/graph_definition.py | 289 ++++--- .../webhook_service.cpython-312.pyc | Bin 31866 -> 57297 bytes app/services/webhook_service.py | 786 +++++++++++++++--- 6 files changed, 873 insertions(+), 206 deletions(-) create mode 100644 app/active_sessions.json diff --git a/app/__pycache__/config.cpython-312.pyc b/app/__pycache__/config.cpython-312.pyc index 6c5b2ddf2b9c6b8a9d081c12346ccb52b1b172f8..43be48dce6ffc497ad3611981427955cede233b3 100644 GIT binary patch delta 296 zcmaFHHj{(*G%qg~0}y;Si^#Y#kynyQi*cgHwuyd{@$9P^K@toMQ5>lp#T-lwshlYs zFtH}aR2HZhCz2Q|RE!Hrj14NrjS#D3)a02g$5wy*frkY#mPC|+27AE zJ~$+()Cy=Xb}5Ct!;AA3Qj1FtFH}fLP0P&7Oi5J8%U4J&DlE+`%U94#%`2Ncn{hQG z*JO7lGo~Vr$yH3=T!KLJK;9`{H~BnMoXQPJr5Qr=C1*-rP`17-X>)-=>><1CO)ilO YqJfvWf-bNGJ!BW2Y{YE9!VlB~06U3F>i_@% delta 140 zcmbQq@r;f4G%qg~0}wnY3d~TQ$ScY8iD9C~HiuM}Vh$#TRE`w()r=r%28JfaR92`M z2a*^YRE!fLR>`QzH93s2cCr@JYDUh<*O<&E3o?6f2>=ZNnN{3BIhi?*^&=Ythsflo K%myrcKtTW|3LHWJ 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 19bc54919534bc1116939e195ed608f4741a1679..7983f24bcf5a0225f021b062976f8ed450cd1a75 100644 GIT binary patch literal 57297 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 F{2#-+KnMT; delta 8497 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%0DDf0pIx2 zx0FjZ=VR9W*3k}3`br<~I4{nI>@Y*=1xL-{t>^6XN4wrN*!lfs#dBMq>pV$M*ClW3 zo7k{3+0mcO4qPzqf(+1B3?V1Dh83XBP5ziP4 zk#>;{f_SDfAL%BtK_i}7qDQ)g0KP78)F7%FxJ%zX7CAz+}>n-1$usJtzztW9cVpy z+;Jms%O_JiK!fxwiv9pV)%$}1I2GwRED%{S!aH-Oq>mIng9t=3R{G4lV&WyB>B|V? z2(KW#itri&Ul_PnCas*dST&>DVvE+H`-1b5s#GBKHpc z06V-4po%myq@+D!kz~aWcLAOn2uHQko}s@6(W4sPg8nDa;3~c?jf%`O2*pZ&3T*ms z2;hpb@Aryt0L?0o*D>q!hJ-ttrk4o{>_GT2!dnPQgb9T7@#(48n>PAhX{18zaE;CHnsc{u0fY?S(IDvQ+cr z=M1>Mj2-^rHcj*Yxdg$NurJ(Nit9twW41M*(5p~Dmo%MRf~zBPvOY2GiO+HZA3FEM zXN}*GRyZ}*Pc0zmPQi=u z<4=~;k5Tp-!k-ZE+D`w1@K*qK{I!PC-(cx~0QThE9EtgYE*MBV%!w4)Q_Eg>t*p|g zxL_QLt1Gh0r_kS`q!GWIVVM#FYQ)n^byR~?&*?=8Ef7>h&;fK)J(321PmbdV_KfgV z9LJMDOpk+%c3!g8j&@wKyD+>91yz@wvyOz4U6Bb}^pbN{vVK)^b9}-!lG17Ov#;p% zCUeRr6u2%s3u=-JZcN@bJYm~&$yuG8-A~ zN!5)pqS_NHT|D)L*z-5|dwe>0UK1%TOC$a(IyIZh{y-GtyO*Elp9+{P@YV{eo2YuQ2u}gnAJ4p1 zllukG1)=2+s8W=CF(3QcTk8y1gfpBiNNy)C_T^;Jj14p$F>XSszqy}2V=K`Iu^UtS zZokY!UOM0#z22}dBzwJXb9&)UjvAE1pI6gCreOLUn;BLZS#8h9&`pnK;4NGM9u2E} zrhaVSgiBjM)a)TS=#M}T5NpBN?pwiVcItdZ)+%hY2yN0t7`f1F8$^Z|VGnZM?AsSA z*zg65-h#z)D3)tz7W>wPItxZK{@I@9u%BLN)Z4I}%ZlEqD$7G*J-?9dq;}-<@RN^J z0o(pgRf`izyk)A65PXUcD)mEhZ+|2L0RxK^?-aBcp#-56p$tG3qrO-_`@uWSiALn% zT)_AI-Ehh%su+pNVKw_sdDz<*2+Cf6zwE!0{vJhgk%@UGu8%ao2wbZgGE#hT6(h@- zX|GylG@@HjO-|IOC_@nnU!K`r7mLUx_Qi_>37k}le~VCcy~8nCq1%w*L+D2sLHG*7 zmk==FOXrHeKnf!k#n6}zMtmpZWFCc&Bgn%5opc90YmJrhP zo{;quq3)be_nu&RPq4lxnEq%Mi2FSu`vYP56{BD+JC{{HYD#4b_WaSDOT}eL!TI|v zmi=ylE=bM^caGJ)d#fgq>IYr5hwUi=UPlT~RG(0uTXSOfv-K11)~m=%>8+%n9PwWj z@SQ3zA)T7T%2fg1skLM&=^-Z;rUZPS^rzp+ZQiS#ks5^Wu$|G~-ZN^&!K91OsBY6JW82dg#kx1N@N z%_n;cP2jCpD(7xksD0IB1p3tmQ)i3tYICc;vqAfsV28qM1+zC=h1VDAHmo$fz7&Pd z3cAjE?O9!Ur(1j0&FKbR=W@f@CL_q7ZLx1OYu|8)h=p@D>a=g_bU?pp)orZRzL{s; LSfhQj2I>C=hOx*( 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."} ] }, {