service.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389
  1. import asyncio
  2. import json
  3. import logging
  4. from datetime import datetime, timezone
  5. import logging
  6. from app.call_context import CallContextService
  7. from app.call_context_repository import CallContextRepository
  8. from app.kontor_mcp import KontorMCPClient
  9. logger = logging.getLogger(__name__)
  10. from .config import Settings
  11. from .repository import Repository
  12. from .threecx import ThreeCXClient
  13. log = logging.getLogger(__name__)
  14. class TelephonyService:
  15. def __init__(self, settings: Settings):
  16. self.settings = settings
  17. self.repo = Repository(
  18. settings.database_path
  19. )
  20. self.cx = ThreeCXClient(
  21. settings
  22. )
  23. # Long-lived Kontor MCP client.
  24. self.kontor_mcp = KontorMCPClient(
  25. url=settings.kontor_mcp_url,
  26. token=settings.kontor_mcp_token,
  27. timeout=settings.kontor_mcp_timeout,
  28. )
  29. # Generic customer/business context service.
  30. self.call_context = CallContextService(
  31. self.kontor_mcp
  32. )
  33. # Persisted enrichment snapshots.
  34. self._call_context_repository = (
  35. CallContextRepository()
  36. )
  37. # One enrichment task per (callid, legid).
  38. self._call_context_tasks = {}
  39. self._active_participants = []
  40. self._active_observed_at = None
  41. self._last_logged_active_state = None
  42. async def start(self):
  43. await self.repo.init()
  44. # Initialize the long-lived MCP session once.
  45. await self.kontor_mcp.initialize()
  46. asyncio.create_task(
  47. self._snapshot_loop()
  48. )
  49. asyncio.create_task(
  50. self.cx.run_websocket(
  51. self._on_event
  52. )
  53. )
  54. async def _on_event(self, event):
  55. log.debug("3CX event: %s", event)
  56. await self._process_queue()
  57. async def _snapshot_loop(self):
  58. while True:
  59. try:
  60. await self._process_queue()
  61. except Exception:
  62. log.exception("Queue snapshot failed")
  63. try:
  64. await self._process_outbound()
  65. except Exception:
  66. log.exception("Outbound tracking failed")
  67. await asyncio.sleep(2)
  68. def get_active_participants(self):
  69. return list(self._active_participants)
  70. async def _process_outbound(self):
  71. """
  72. Verfolgt ausschließlich von der Outbound-API gestartete Calls.
  73. Ein einzelner Participant-Request wird pro source_dn ausgeführt.
  74. Fehler bei 3CX werden NICHT als Call-Ende interpretiert.
  75. """
  76. calls = await self.repo.list_active_outbound_calls()
  77. if not calls:
  78. return
  79. now = datetime.now(timezone.utc)
  80. now_iso = now.isoformat()
  81. # Nur die tatsächlich benötigten DNs abfragen.
  82. dns = sorted({
  83. str(call["source_dn"])
  84. for call in calls
  85. if call.get("source_dn")
  86. })
  87. participants_by_dn = {}
  88. for dn in dns:
  89. try:
  90. participants_by_dn[dn] = await self.cx.get_participants(dn)
  91. except Exception:
  92. # Ganz wichtig:
  93. # Bei einem 3CX-/Netzwerkfehler niemals den Call
  94. # fälschlich als beendet markieren.
  95. log.exception(
  96. "Outbound participant lookup failed for DN %s",
  97. dn,
  98. )
  99. for call in calls:
  100. source_dn = str(call["source_dn"])
  101. participants = participants_by_dn.get(source_dn)
  102. # Wenn die Abfrage für diesen DN fehlgeschlagen ist,
  103. # bleibt der Call unverändert.
  104. if participants is None:
  105. continue
  106. callid = str(call["callid"])
  107. legid = str(call["legid"]) if call["legid"] is not None else None
  108. participant = None
  109. for p in participants:
  110. if str(p.get("callid")) != callid:
  111. continue
  112. if legid is not None and str(p.get("legid")) != legid:
  113. continue
  114. participant = p
  115. break
  116. if participant is None:
  117. # Call ist nicht mehr bei 3CX aktiv.
  118. started = None
  119. answered = None
  120. try:
  121. if call.get("started_at"):
  122. started = datetime.fromisoformat(
  123. call["started_at"].replace("Z", "+00:00")
  124. )
  125. if call.get("answered_at"):
  126. answered = datetime.fromisoformat(
  127. call["answered_at"].replace("Z", "+00:00")
  128. )
  129. except Exception:
  130. log.exception(
  131. "Could not parse timestamps for outbound call %s",
  132. call["id"],
  133. )
  134. duration = None
  135. if answered:
  136. duration = max(
  137. 0,
  138. int((now - answered).total_seconds()),
  139. )
  140. final_status = "ended" if answered else "failed"
  141. await self.repo.finalize_outbound_call(
  142. call_id=call["id"],
  143. ended_at=now_iso,
  144. status=final_status,
  145. duration_seconds=duration,
  146. )
  147. log.info(
  148. "OUTBOUND_CALL_ENDED callid=%s legid=%s status=%s duration=%s",
  149. callid,
  150. legid,
  151. final_status,
  152. duration,
  153. )
  154. continue
  155. status = str(participant.get("status") or "").lower()
  156. if status == "connected":
  157. if not call.get("answered_at"):
  158. await self.repo.update_outbound_connected(
  159. call_id=call["id"],
  160. answered_at=now_iso,
  161. last_seen_at=now_iso,
  162. )
  163. log.info(
  164. "OUTBOUND_CALL_CONNECTED callid=%s legid=%s source=%s destination=%s",
  165. callid,
  166. legid,
  167. source_dn,
  168. call.get("destination"),
  169. )
  170. else:
  171. await self.repo.update_outbound_seen(
  172. call_id=call["id"],
  173. status="connected",
  174. last_seen_at=now_iso,
  175. )
  176. elif status:
  177. await self.repo.update_outbound_seen(
  178. call_id=call["id"],
  179. status=status,
  180. last_seen_at=now_iso,
  181. )
  182. async def _enrich_and_persist_call_context(
  183. self,
  184. participant,
  185. ):
  186. """
  187. Enrich one external inbound call with Kontor MCP context
  188. and persist the resulting snapshot.
  189. This task is deliberately detached from the 3CX event loop.
  190. """
  191. callid = participant.get("callid")
  192. legid = participant.get("legid")
  193. raw_phone = participant.get("party_caller_id")
  194. if not callid or not raw_phone:
  195. return
  196. key = (
  197. str(callid),
  198. str(legid) if legid is not None else None,
  199. )
  200. try:
  201. # Existing middleware phone normalization.
  202. e164 = self.repo.normalize_phone(
  203. raw_phone
  204. )
  205. if not e164:
  206. logger.warning(
  207. "CALL_CONTEXT invalid phone callid=%s legid=%s raw=%s",
  208. callid,
  209. legid,
  210. raw_phone,
  211. )
  212. return
  213. context = await self.call_context.enrich_phone(
  214. e164
  215. )
  216. self._call_context_repository.upsert(
  217. callid=str(callid),
  218. legid=(
  219. str(legid)
  220. if legid is not None
  221. else None
  222. ),
  223. phone_e164=e164,
  224. context=context,
  225. )
  226. logger.info(
  227. "CALL_CONTEXT persisted callid=%s legid=%s phone=%s status=%s",
  228. callid,
  229. legid,
  230. e164,
  231. context.get("status"),
  232. )
  233. except Exception:
  234. logger.exception(
  235. "CALL_CONTEXT enrichment failed callid=%s legid=%s",
  236. callid,
  237. legid,
  238. )
  239. finally:
  240. self._call_context_tasks.pop(
  241. key,
  242. None,
  243. )
  244. async def _process_queue(self):
  245. data = await self.cx.get_dn(self.settings.threecx_queue_dn)
  246. participants = data.get("participants", [])
  247. self._active_participants = participants
  248. self._active_observed_at = asyncio.get_running_loop().time()
  249. external = [
  250. p for p in participants
  251. if p.get("party_dn_type") == "Wexternalline"
  252. ]
  253. # Enrich each new external call exactly once.
  254. for participant in external:
  255. callid = participant.get("callid")
  256. legid = participant.get("legid")
  257. if not callid:
  258. continue
  259. key = (
  260. str(callid),
  261. str(legid) if legid is not None else None,
  262. )
  263. if key in self._call_context_tasks:
  264. continue
  265. task = asyncio.create_task(
  266. self._enrich_and_persist_call_context(
  267. participant
  268. )
  269. )
  270. self._call_context_tasks[key] = task
  271. if external:
  272. state = []
  273. for p in external:
  274. state.append({
  275. "callId": p.get("callid"),
  276. "legId": p.get("legid"),
  277. "status": p.get("status"),
  278. "caller": p.get("party_caller_id"),
  279. "partyDn": p.get("party_dn"),
  280. "deviceId": p.get("device_id"),
  281. })
  282. state.sort(
  283. key=lambda x: (
  284. str(x.get("callId")),
  285. str(x.get("legId")),
  286. )
  287. )
  288. fingerprint = json.dumps(
  289. state,
  290. sort_keys=True,
  291. ensure_ascii=False,
  292. separators=(",", ":"),
  293. )
  294. if fingerprint != self._last_logged_active_state:
  295. logger.info(
  296. "CALL_STATE %s",
  297. json.dumps(
  298. {
  299. "queue": self.settings.threecx_queue_dn,
  300. "calls": state,
  301. },
  302. ensure_ascii=False,
  303. separators=(",", ":"),
  304. ),
  305. )
  306. self._last_logged_active_state = fingerprint
  307. else:
  308. self._last_logged_active_state = None
  309. active = await self.repo.observe_queue(
  310. participants,
  311. self.settings.threecx_queue_dn,
  312. self.settings.threecx_monitored_extensions,
  313. )
  314. await self.repo.finalize_disappeared(self.settings.threecx_queue_dn, active)