from __future__ import annotations import logging import os import re import time import unicodedata as ud from dataclasses import ( asdict, is_dataclass, ) from datetime import ( datetime, timezone, ) from decimal import ( Decimal, InvalidOperation, ) from typing import Any from pydantic import BaseModel from agente_contas_tim.integrations import agent_framework_bridge from agente_contas_tim.integrations.noc_events import emit_api_content_mismatch from agente_contas_tim.models.service_info import ServiceInfo from agente_contas_tim.observability import get_session_id _IDEMPOTENCY_TTL_SECONDS = int(os.getenv("TIM_IDEMPOTENCY_TTL_SECONDS", "3600")) _IDEMPOTENCY_CACHE: dict[str, tuple[float, dict[str, Any]]] = {} logger = logging.getLogger(__name__) def _emit_vaa( tag: str, ic_base: dict[str, Any], *, gsm: str = "", extra_metadata: dict[str, Any] | None = None, ) -> None: """Emite um IC do fluxo VAS Avulso (VAA.001–VAA.017).""" try: metadata = { **ic_base, "tag": tag, "eventDate": int(datetime.now(timezone.utc).timestamp() * 1000), } if isinstance(extra_metadata, dict): metadata.update(extra_metadata) if gsm: metadata["gsm"] = gsm agent_framework_bridge.event(tag, metadata=metadata) except Exception: logger.warning("_emit_vaa tag=%s falhou silenciosamente", tag) def _build_ic_context( input_state: dict[str, Any] | None, *, gsm: str = "", ) -> dict[str, Any]: if not isinstance(input_state, dict): input_state = {} return { "sessionId": str(get_session_id() or ""), "gsm": str(gsm or "").strip(), "ani": str(input_state.get("ani", "") or "").strip(), "uraCallId": str(input_state.get("ura_call_id", "") or "").strip(), "agentId": "contas", "channelId": str( input_state.get("channel_id") or input_state.get("channelId") or "URA" ).strip() or "URA", } def _emit_ic( ic_code: str, input_state: dict[str, Any], *, extra_metadata: dict[str, Any] | None = None, ) -> None: """Emite um IC via agent_framework_bridge com metadados padrao.""" try: metadata: dict[str, Any] = { "sessionId": str(get_session_id() or ""), "tag": ic_code, "eventDate": int(datetime.now(timezone.utc).timestamp() * 1000), "uraCallId": str(input_state.get("ura_call_id", "") or "").strip(), "gsm": str(input_state.get("msisdn", "") or "").strip(), "agentId": "contas", "channelId": str( input_state.get("channel_id") or input_state.get("channelId") or "URA" ).strip() or "URA", } ani = str(input_state.get("ani", "") or "").strip() if ani: metadata["ani"] = ani message_id = str( input_state.get("message_id") or input_state.get("messageId") or "" ).strip() if message_id: metadata["messageId"] = message_id if isinstance(extra_metadata, dict): metadata.update(extra_metadata) agent_framework_bridge.event(ic_code, metadata=metadata) except Exception: logger.debug("_emit_ic: falha ao emitir %s", ic_code, exc_info=True) def _idempotency_get(key: str) -> dict[str, Any] | None: if not key: return None now = time.monotonic() cached = _IDEMPOTENCY_CACHE.get(key) if cached is None: return None created_at, value = cached if now - created_at > _IDEMPOTENCY_TTL_SECONDS: _IDEMPOTENCY_CACHE.pop(key, None) return None return value def _idempotency_set(key: str, value: dict[str, Any]) -> None: if not key: return _IDEMPOTENCY_CACHE[key] = (time.monotonic(), value) def _utc_now_iso() -> str: return datetime.now(timezone.utc).isoformat() def _to_epoch_millis(value: Any) -> int: if isinstance(value, datetime): if value.tzinfo is None: value = value.replace(tzinfo=timezone.utc) return int(value.timestamp() * 1000) raw = str(value or "").strip() if not raw: return int(datetime.now(timezone.utc).timestamp() * 1000) if re.fullmatch(r"\d+(?:\.\d+)?", raw): number = float(raw) if number > 100_000_000_000: return int(number) return int(number * 1000) iso_raw = raw[:-1] + "+00:00" if raw.endswith("Z") else raw try: parsed = datetime.fromisoformat(iso_raw) except ValueError: return int(datetime.now(timezone.utc).timestamp() * 1000) if parsed.tzinfo is None: parsed = parsed.replace(tzinfo=timezone.utc) return int(parsed.timestamp() * 1000) def _to_bool(value: Any) -> bool: if isinstance(value, bool): return value text = str(value or "").strip().lower() return text in {"1", "true", "t", "sim", "yes", "y"} def _to_dict(data: Any) -> Any: if isinstance(data, BaseModel): return data.model_dump(by_alias=True) if is_dataclass(data): return _to_dict(asdict(data)) if isinstance(data, tuple): return [_to_dict(item) for item in data] if isinstance(data, list): return [_to_dict(item) for item in data] if isinstance(data, dict): return {key: _to_dict(value) for key, value in data.items()} return data def _result_failed_or_missing_data(result: Any, *, state: dict[str, Any]) -> bool: if not getattr(result, "success", False): return True if getattr(result, "data", None) is not None: return False input_state = state.get("input", {}) if isinstance(state, dict) else {} if not isinstance(input_state, dict): input_state = {} emit_api_content_mismatch( values={ "msisdn": str(input_state.get("msisdn", "") or "").strip(), "invoice_id": str( input_state.get("invoice_id") or input_state.get("current_invoice_number") or "" ).strip(), "channel_id": str(input_state.get("channel_id", "") or "").strip(), "ura_call_id": str(input_state.get("ura_call_id", "") or "").strip(), }, reason="workflow_action_command_success_without_data", command=str(getattr(result, "metadata", {}).get("command", "") or ""), api_response_payload=getattr(result, "metadata", {}), ) return True def _runtime_llm_callbacks(runtime: WorkflowRuntimeContext) -> list[Any] | None: callbacks = list(runtime.llm_callbacks) return callbacks or None def _runtime_llm_metadata(runtime: WorkflowRuntimeContext) -> dict[str, Any]: return dict(runtime.llm_metadata) def _service_from_dict(service: dict[str, Any] | None) -> ServiceInfo | None: if service is None: return None return ServiceInfo( msisdn=str(service.get("msisdn", "")), app_id=str(service.get("app_id", "")), csp_id=str(service.get("csp_id", "")), ippid=str(service.get("ippid", "")), service_name=str(service.get("service_name", "")), status=str(service.get("status", "")), extra=dict(service.get("extra", {})), ) def _extract_amount(extra: dict[str, Any] | None) -> str: if not isinstance(extra, dict): return "" details = extra.get("details", {}) if not isinstance(details, dict): details = {} return str( extra.get("valor") or extra.get("price") or details.get("valor") or details.get("price") or "" ) def _extract_boleto_code(extra: dict[str, Any] | None) -> str: if not isinstance(extra, dict): return "" return str( extra.get("codigo_boleto") or extra.get("boleto_code") or extra.get("linha_digitavel") or "" ).strip() def _find_matching_service( requested_name: str, active_services: dict[str, dict[str, Any]], ) -> dict[str, Any] | None: lowered_name = requested_name.strip().lower() exact = active_services.get(lowered_name) if exact: return exact normalized_requested = _normalize_service_name(lowered_name) for key, value in active_services.items(): if _normalize_service_name(key) == normalized_requested: return value return next( ( value for key, value in active_services.items() if ( lowered_name in key or key in lowered_name or normalized_requested in _normalize_service_name(key) or _normalize_service_name(key) in normalized_requested ) ), None, ) def _normalize_service_name(value: str) -> str: text = ud.normalize("NFKD", str(value or "")) text = "".join(ch for ch in text if not ud.combining(ch)) text = re.sub(r"[^a-zA-Z0-9]+", " ", text).strip().lower() return re.sub(r"\s+", " ", text) def _resolve_service_csp_id( matched_service: dict[str, Any], *, fallback_csp_id: str, ) -> str: if not isinstance(matched_service, dict): return fallback_csp_id context = matched_service.get("service_context") if isinstance(context, dict): csp_id = str( context.get("csp_id") or context.get("cspId") or ( context.get("csp", {}).get("id") if isinstance(context.get("csp"), dict) else "" ) or "" ).strip() if csp_id: return csp_id return fallback_csp_id def _normalize_amount(value: Any) -> str: text = str(value or "").strip() if not text: return "" text = ( text.replace("R$", "") .replace("$", "") .replace("\u00a0", "") .strip() ) text = text.replace("−", "-").replace("–", "-") has_neg = text.startswith("-") if has_neg: text = text[1:] else: text = text.lstrip("+") text = re.sub(r"\s+", "", text) text = re.sub(r"[^0-9.,-]", "", text) if not text: return "" has_any = ("." in text) or ("," in text) if not has_any: return f"{'-' if has_neg else ''}{text}" if "." in text and "," in text: last_dot = text.rfind(".") last_comma = text.rfind(",") if last_comma > last_dot: int_part = text[:last_comma].replace(".", "") frac_part = text[last_comma + 1 :] else: int_part = text[:last_dot].replace(",", "") frac_part = text[last_dot + 1 :] normalized = f"{'-' if has_neg else ''}{int_part}.{frac_part}" return normalized sep = "." if "." in text else "," parts = text.split(sep) if len(parts) > 2: frac = parts[-1] if len(frac) <= 2: normalized = f"{'-' if has_neg else ''}{''.join(parts[:-1])}.{frac}" else: normalized = f"{'-' if has_neg else ''}{''.join(parts)}" return normalized int_part, frac_part = parts[0], parts[1] if len(frac_part) <= 2: normalized = ( f"{'-' if has_neg else ''}{int_part.replace(',', '').replace('.', '')}." f"{frac_part}" ) else: normalized = f"{'-' if has_neg else ''}{int_part}{frac_part}" return normalized def _parse_amount(value: Any) -> Decimal | None: normalized = _normalize_amount(value) if not normalized: return None try: return Decimal(normalized) except (InvalidOperation, ValueError): return None def _format_amount(value: Decimal | None) -> str: if value is None: return "" return f"{value:.2f}".replace(".", ",") def _build_sr_notes( *, sms_enviado: bool, codigo_boleto: str, data_credito_proxima_fatura: str, ) -> str: if sms_enviado: return ( f"SMS com código do boleto enviado. Código: {codigo_boleto}." if codigo_boleto else "SMS com código do boleto enviado." ) if data_credito_proxima_fatura: return ( "Crédito do valor do serviço cancelado registrado " f"na próxima fatura com vencimento em {data_credito_proxima_fatura}." ) return "Crédito do valor do serviço cancelado registrado na próxima fatura." def _format_list_pt_br(values: list[str]) -> str: cleaned = [str(item).strip() for item in values if str(item).strip()] if not cleaned: return "" if len(cleaned) == 1: return cleaned[0] if len(cleaned) == 2: return f"{cleaned[0]} e {cleaned[1]}" return f"{', '.join(cleaned[:-1])} e {cleaned[-1]}" def _final_msisdn(msisdn: str) -> str: digits = re.sub(r"\D", "", msisdn) if len(digits) >= 4: return digits[-4:] return msisdn[-4:] if len(msisdn) >= 4 else msisdn def _first_text_from_params_or_state( state: dict[str, Any], params: dict[str, Any], *keys: str, ) -> str: input_state = state.get("input", {}) if isinstance(state, dict) else {} sources: list[dict[str, Any]] = [] if isinstance(params, dict): sources.append(params) if isinstance(input_state, dict): sources.append(input_state) for source in sources: for key in keys: value = source.get(key) if value is None: continue text = str(value).strip() if text: return text return "" def _first_value_from_params_or_state( state: dict[str, Any], params: dict[str, Any], *keys: str, ) -> Any: input_state = state.get("input", {}) if isinstance(state, dict) else {} sources: list[dict[str, Any]] = [] if isinstance(params, dict): sources.append(params) if isinstance(input_state, dict): sources.append(input_state) for source in sources: for key in keys: if key not in source: continue value = source.get(key) if value is None: continue if isinstance(value, str): text = value.strip() if text: return text continue return value return None def _contains_any_keyword(value: str, keywords: tuple[str, ...]) -> bool: normalized = str(value or "").strip().lower() if not normalized: return False return any(keyword in normalized for keyword in keywords) def _normalize_number_text(value: Any, *, default: str = "0") -> str: text = str(value).strip() if not text: return default cleaned = text.replace("R$", "").replace(" ", "") if "," in cleaned: cleaned = cleaned.replace(".", "").replace(",", ".") try: normalized = format(Decimal(cleaned), "f") except (InvalidOperation, ValueError): return default if "." in normalized: normalized = normalized.rstrip("0").rstrip(".") return normalized or default def _normalize_bool(value: Any, *, default: bool = False) -> bool: if isinstance(value, bool): return value if isinstance(value, (int, float)): return bool(value) text = str(value).strip().lower() if not text: return default if text in {"true", "1", "sim", "yes", "y"}: return True if text in {"false", "0", "nao", "não", "no", "n"}: return False return default def _map_customer_status_code(value: Any) -> str: text = str(value).strip().lower() if not text: return "" if text in {"0", "1", "2", "3"}: return text if "inativ" in text: return "0" if "ativ" in text: return "1" if "suspens" in text: return "2" if "bloque" in text: return "3" return "" def _text_from_keys(source: Any, keys: tuple[str, ...]) -> str: if not isinstance(source, dict): return "" return " ".join(str(source.get(key) or "") for key in keys).strip() __all__ = [ '_emit_vaa', '_build_ic_context', '_emit_ic', '_idempotency_get', '_idempotency_set', '_utc_now_iso', '_to_epoch_millis', '_to_bool', '_to_dict', '_result_failed_or_missing_data', '_runtime_llm_callbacks', '_runtime_llm_metadata', '_service_from_dict', '_extract_amount', '_extract_boleto_code', '_find_matching_service', '_normalize_service_name', '_resolve_service_csp_id', '_parse_amount', '_format_amount', '_build_sr_notes', '_format_list_pt_br', '_final_msisdn', '_first_text_from_params_or_state', '_first_value_from_params_or_state', '_contains_any_keyword', '_normalize_number_text', '_normalize_bool', '_map_customer_status_code', '_text_from_keys', ]