| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389 |
- import asyncio
- import json
- import logging
- from datetime import datetime, timezone
- import logging
- from app.call_context import CallContextService
- from app.call_context_repository import CallContextRepository
- from app.kontor_mcp import KontorMCPClient
- logger = logging.getLogger(__name__)
- from .config import Settings
- from .repository import Repository
- from .threecx import ThreeCXClient
- log = logging.getLogger(__name__)
- class TelephonyService:
- def __init__(self, settings: Settings):
- self.settings = settings
- self.repo = Repository(
- settings.database_path
- )
- self.cx = ThreeCXClient(
- settings
- )
- # Long-lived Kontor MCP client.
- self.kontor_mcp = KontorMCPClient(
- url=settings.kontor_mcp_url,
- token=settings.kontor_mcp_token,
- timeout=settings.kontor_mcp_timeout,
- )
- # Generic customer/business context service.
- self.call_context = CallContextService(
- self.kontor_mcp
- )
- # Persisted enrichment snapshots.
- self._call_context_repository = (
- CallContextRepository()
- )
- # One enrichment task per (callid, legid).
- self._call_context_tasks = {}
- self._active_participants = []
- self._active_observed_at = None
- self._last_logged_active_state = None
- async def start(self):
- await self.repo.init()
- # Initialize the long-lived MCP session once.
- await self.kontor_mcp.initialize()
- asyncio.create_task(
- self._snapshot_loop()
- )
- asyncio.create_task(
- self.cx.run_websocket(
- self._on_event
- )
- )
- async def _on_event(self, event):
- log.debug("3CX event: %s", event)
- await self._process_queue()
- async def _snapshot_loop(self):
- while True:
- try:
- await self._process_queue()
- except Exception:
- log.exception("Queue snapshot failed")
- try:
- await self._process_outbound()
- except Exception:
- log.exception("Outbound tracking failed")
- await asyncio.sleep(2)
- def get_active_participants(self):
- return list(self._active_participants)
- async def _process_outbound(self):
- """
- Verfolgt ausschließlich von der Outbound-API gestartete Calls.
- Ein einzelner Participant-Request wird pro source_dn ausgeführt.
- Fehler bei 3CX werden NICHT als Call-Ende interpretiert.
- """
- calls = await self.repo.list_active_outbound_calls()
- if not calls:
- return
- now = datetime.now(timezone.utc)
- now_iso = now.isoformat()
- # Nur die tatsächlich benötigten DNs abfragen.
- dns = sorted({
- str(call["source_dn"])
- for call in calls
- if call.get("source_dn")
- })
- participants_by_dn = {}
- for dn in dns:
- try:
- participants_by_dn[dn] = await self.cx.get_participants(dn)
- except Exception:
- # Ganz wichtig:
- # Bei einem 3CX-/Netzwerkfehler niemals den Call
- # fälschlich als beendet markieren.
- log.exception(
- "Outbound participant lookup failed for DN %s",
- dn,
- )
- for call in calls:
- source_dn = str(call["source_dn"])
- participants = participants_by_dn.get(source_dn)
- # Wenn die Abfrage für diesen DN fehlgeschlagen ist,
- # bleibt der Call unverändert.
- if participants is None:
- continue
- callid = str(call["callid"])
- legid = str(call["legid"]) if call["legid"] is not None else None
- participant = None
- for p in participants:
- if str(p.get("callid")) != callid:
- continue
- if legid is not None and str(p.get("legid")) != legid:
- continue
- participant = p
- break
- if participant is None:
- # Call ist nicht mehr bei 3CX aktiv.
- started = None
- answered = None
- try:
- if call.get("started_at"):
- started = datetime.fromisoformat(
- call["started_at"].replace("Z", "+00:00")
- )
- if call.get("answered_at"):
- answered = datetime.fromisoformat(
- call["answered_at"].replace("Z", "+00:00")
- )
- except Exception:
- log.exception(
- "Could not parse timestamps for outbound call %s",
- call["id"],
- )
- duration = None
- if answered:
- duration = max(
- 0,
- int((now - answered).total_seconds()),
- )
- final_status = "ended" if answered else "failed"
- await self.repo.finalize_outbound_call(
- call_id=call["id"],
- ended_at=now_iso,
- status=final_status,
- duration_seconds=duration,
- )
- log.info(
- "OUTBOUND_CALL_ENDED callid=%s legid=%s status=%s duration=%s",
- callid,
- legid,
- final_status,
- duration,
- )
- continue
- status = str(participant.get("status") or "").lower()
- if status == "connected":
- if not call.get("answered_at"):
- await self.repo.update_outbound_connected(
- call_id=call["id"],
- answered_at=now_iso,
- last_seen_at=now_iso,
- )
- log.info(
- "OUTBOUND_CALL_CONNECTED callid=%s legid=%s source=%s destination=%s",
- callid,
- legid,
- source_dn,
- call.get("destination"),
- )
- else:
- await self.repo.update_outbound_seen(
- call_id=call["id"],
- status="connected",
- last_seen_at=now_iso,
- )
- elif status:
- await self.repo.update_outbound_seen(
- call_id=call["id"],
- status=status,
- last_seen_at=now_iso,
- )
- async def _enrich_and_persist_call_context(
- self,
- participant,
- ):
- """
- Enrich one external inbound call with Kontor MCP context
- and persist the resulting snapshot.
- This task is deliberately detached from the 3CX event loop.
- """
- callid = participant.get("callid")
- legid = participant.get("legid")
- raw_phone = participant.get("party_caller_id")
- if not callid or not raw_phone:
- return
- key = (
- str(callid),
- str(legid) if legid is not None else None,
- )
- try:
- # Existing middleware phone normalization.
- e164 = self.repo.normalize_phone(
- raw_phone
- )
- if not e164:
- logger.warning(
- "CALL_CONTEXT invalid phone callid=%s legid=%s raw=%s",
- callid,
- legid,
- raw_phone,
- )
- return
- context = await self.call_context.enrich_phone(
- e164
- )
- self._call_context_repository.upsert(
- callid=str(callid),
- legid=(
- str(legid)
- if legid is not None
- else None
- ),
- phone_e164=e164,
- context=context,
- )
- logger.info(
- "CALL_CONTEXT persisted callid=%s legid=%s phone=%s status=%s",
- callid,
- legid,
- e164,
- context.get("status"),
- )
- except Exception:
- logger.exception(
- "CALL_CONTEXT enrichment failed callid=%s legid=%s",
- callid,
- legid,
- )
- finally:
- self._call_context_tasks.pop(
- key,
- None,
- )
- async def _process_queue(self):
- data = await self.cx.get_dn(self.settings.threecx_queue_dn)
- participants = data.get("participants", [])
- self._active_participants = participants
- self._active_observed_at = asyncio.get_running_loop().time()
- external = [
- p for p in participants
- if p.get("party_dn_type") == "Wexternalline"
- ]
- # Enrich each new external call exactly once.
- for participant in external:
- callid = participant.get("callid")
- legid = participant.get("legid")
- if not callid:
- continue
- key = (
- str(callid),
- str(legid) if legid is not None else None,
- )
- if key in self._call_context_tasks:
- continue
- task = asyncio.create_task(
- self._enrich_and_persist_call_context(
- participant
- )
- )
- self._call_context_tasks[key] = task
- if external:
- state = []
- for p in external:
- state.append({
- "callId": p.get("callid"),
- "legId": p.get("legid"),
- "status": p.get("status"),
- "caller": p.get("party_caller_id"),
- "partyDn": p.get("party_dn"),
- "deviceId": p.get("device_id"),
- })
- state.sort(
- key=lambda x: (
- str(x.get("callId")),
- str(x.get("legId")),
- )
- )
- fingerprint = json.dumps(
- state,
- sort_keys=True,
- ensure_ascii=False,
- separators=(",", ":"),
- )
- if fingerprint != self._last_logged_active_state:
- logger.info(
- "CALL_STATE %s",
- json.dumps(
- {
- "queue": self.settings.threecx_queue_dn,
- "calls": state,
- },
- ensure_ascii=False,
- separators=(",", ":"),
- ),
- )
- self._last_logged_active_state = fingerprint
- else:
- self._last_logged_active_state = None
- active = await self.repo.observe_queue(
- participants,
- self.settings.threecx_queue_dn,
- self.settings.threecx_monitored_extensions,
- )
- await self.repo.finalize_disappeared(self.settings.threecx_queue_dn, active)
|