from __future__ import annotations from dataclasses import dataclass, field from types import SimpleNamespace from typing import Any from uuid import uuid4 @dataclass(slots=True) class BackofficeChannelEnvelope: """Canonical channel envelope for Backoffice REST requests. REST remains only a transport/channel contract. This envelope carries the normalized information required by the framework layers before a workflow is dispatched. The domain payload is preserved under ``payload`` so existing business nodes can continue to read the original TIM/ANATEL contract from ``state["metadata"]["request_context"]``. """ workflow_id: str payload: dict[str, Any] transaction_id: str channel: str = "backoffice_rest" tenant_id: str = "default" agent_id: str = "backoffice_anatel" user_id: str | None = None message_id: str = field(default_factory=lambda: str(uuid4())) text: str = "" business_context: dict[str, Any] = field(default_factory=dict) metadata: dict[str, Any] = field(default_factory=dict) def gateway_payload(self) -> dict[str, Any]: """Payload sent to the framework ChannelGateway. The web adapter shape is intentionally also populated as a compatibility fallback for framework versions that do not yet register a dedicated ``backoffice_rest`` adapter. """ return { "channel": self.channel, "message": self.text, "text": self.text, "session_id": self.transaction_id, "conversation_key": f"{self.tenant_id}:{self.agent_id}:{self.transaction_id}", "message_id": self.message_id, "user_id": self.user_id or self.business_context.get("customer_key") or self.transaction_id, "tenant_id": self.tenant_id, "agent_id": self.agent_id, "business_context": self.business_context, "metadata": { **self.metadata, "workflow_id": self.workflow_id, "transaction_id": self.transaction_id, "source_channel": self.channel, "request_context": self.payload, }, } class BackofficeRestChannelAdapter: """Adapter that turns legacy Backoffice REST contracts into a framework channel. This class deliberately contains translation only. It does not run LangGraph, does not know node order and does not call domain services. Execution is delegated to the framework workflow dispatcher after channel normalization. """ channel_name = "backoffice_rest" agent_id = "backoffice_anatel" tenant_id = "default" def from_ticket_event(self, event: Any, *, legacy_contract: str) -> BackofficeChannelEnvelope: payload = self._to_dict(event) transaction_id = payload.get("transactionId") or payload.get("transaction_id") or f"man-{uuid4().hex[:8]}" complaint = payload.get("complaint") or {} customer = payload.get("customer") or {} text = self._first_non_empty( complaint.get("description"), complaint.get("motive"), payload.get("description"), f"Processar chamado backoffice {transaction_id}", ) business_context = self._business_context(payload, transaction_id) return BackofficeChannelEnvelope( workflow_id="backoffice_checklist", payload=payload, transaction_id=transaction_id, channel=self.channel_name, tenant_id=self.tenant_id, agent_id=self.agent_id, user_id=customer.get("cpfCnpj") or customer.get("msisdn") or business_context.get("customer_key"), text=text, business_context=business_context, metadata={ "legacy_contract": legacy_contract, "case_type": payload.get("caseType"), "complaint_protocol": complaint.get("complaintProtocol"), "crm_protocol": payload.get("crmProtocol"), "adapter": self.__class__.__name__, }, ) def from_emulator_event(self, event: Any, *, legacy_contract: str) -> BackofficeChannelEnvelope: payload = self._to_dict(event) transaction_id = payload.get("transactionId") or payload.get("transaction_id") or f"emu-{uuid4().hex[:8]}" selected_actions = payload.get("selected_actions") or payload.get("selectedActions") or [] flow_mode = payload.get("flow_mode") or payload.get("flowMode") or payload.get("action") text = self._first_non_empty( payload.get("operator_instructions"), payload.get("previous_response"), f"Executar emulador de resposta do caso {transaction_id}", ) return BackofficeChannelEnvelope( workflow_id="backoffice_response_emulator", payload=payload, transaction_id=transaction_id, channel=self.channel_name, tenant_id=self.tenant_id, agent_id=self.agent_id, user_id=transaction_id, text=text, business_context={"interaction_key": transaction_id, "session_key": transaction_id}, metadata={ "legacy_contract": legacy_contract, "flow_mode": flow_mode, "selected_actions": selected_actions, "adapter": self.__class__.__name__, }, ) async def normalize(self, channel_gateway: Any, envelope: BackofficeChannelEnvelope) -> Any: """Normalize through ChannelGateway, with a compatibility fallback. Newer framework versions may support registering ``backoffice_rest`` as a first-class adapter. Older versions only know web/whatsapp/voice; in that case the payload is still passed through the web adapter shape and then tagged back as ``backoffice_rest``. """ payload = envelope.gateway_payload() try: msg = await channel_gateway.normalize(envelope.channel, payload) return msg except Exception: try: msg = await channel_gateway.normalize("web", payload) try: msg.channel = envelope.channel except Exception: pass return msg except Exception: return SimpleNamespace( channel=envelope.channel, channel_id=None, session_id=envelope.transaction_id, user_id=envelope.user_id, text=envelope.text, context=payload, ) def _business_context(self, payload: dict[str, Any], transaction_id: str) -> dict[str, Any]: customer = payload.get("customer") or {} complaint = payload.get("complaint") or {} cpf = customer.get("cpfCnpj") or (customer.get("subscriber") or {}).get("cpfCnpj") msisdn = customer.get("msisdn") or ((customer.get("phones") or [])[:1] or [None])[0] protocol = complaint.get("complaintProtocol") or payload.get("crmProtocol") or transaction_id return { "customer_key": msisdn or cpf, "contract_key": cpf, "interaction_key": protocol, "session_key": transaction_id, "metadata": { "cpf_cnpj": cpf, "msisdn": msisdn, "transaction_id": transaction_id, }, } def _to_dict(self, value: Any) -> dict[str, Any]: if isinstance(value, dict): return dict(value) if hasattr(value, "model_dump"): return value.model_dump(mode="json", by_alias=True) if hasattr(value, "dict"): return value.dict(by_alias=True) raise TypeError(f"Unsupported backoffice REST event type: {type(value).__name__}") def _first_non_empty(self, *values: Any) -> str: for value in values: if value is not None and str(value).strip(): return str(value).strip() return "Backoffice REST request"