repository.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568
  1. import aiosqlite
  2. from .phone import normalize
  3. from datetime import datetime, timezone, timedelta
  4. def now_iso():
  5. return datetime.now(timezone.utc).isoformat()
  6. class Repository:
  7. def __init__(self, path):
  8. self.path = path
  9. async def init(self):
  10. async with aiosqlite.connect(self.path) as db:
  11. await db.executescript("""
  12. CREATE TABLE IF NOT EXISTS calls (
  13. id INTEGER PRIMARY KEY AUTOINCREMENT,
  14. callid INTEGER NOT NULL,
  15. legid INTEGER,
  16. phone TEXT,
  17. queue_dn TEXT NOT NULL,
  18. first_seen_at TEXT NOT NULL,
  19. last_seen_at TEXT NOT NULL,
  20. status TEXT NOT NULL DEFAULT 'active',
  21. answered_by_dn TEXT,
  22. UNIQUE(queue_dn, callid)
  23. );
  24. CREATE TABLE IF NOT EXISTS missed_call_groups (
  25. phone TEXT PRIMARY KEY,
  26. first_call_at TEXT NOT NULL,
  27. last_call_at TEXT NOT NULL,
  28. attempt_count INTEGER NOT NULL DEFAULT 0,
  29. status TEXT NOT NULL DEFAULT 'open',
  30. customer_id TEXT,
  31. customer_label TEXT,
  32. resolved_until TEXT,
  33. resolved_by TEXT,
  34. resolved_at TEXT,
  35. resolved_note TEXT
  36. );
  37. CREATE TABLE IF NOT EXISTS idempotency_keys (
  38. idem_key TEXT PRIMARY KEY,
  39. operation TEXT NOT NULL,
  40. response_json TEXT NOT NULL,
  41. created_at TEXT NOT NULL
  42. );
  43. """)
  44. await db.commit()
  45. def normalize_phone(self, raw):
  46. """
  47. Zentrale Telefonnummern-Normalisierung.
  48. Die eigentliche Logik liegt in app.phone.normalize().
  49. Dadurch verwendet die Repository-/Queue-Verarbeitung
  50. dieselbe Normalisierung wie die /phone/normalize API.
  51. """
  52. result = normalize(raw, "DE")
  53. if not result.get("valid"):
  54. return None
  55. return result.get("e164")
  56. async def observe_queue(self, participants, queue_dn, agent_dns=None):
  57. active = set()
  58. async with aiosqlite.connect(self.path) as db:
  59. for p in participants:
  60. if p.get("callid") is None:
  61. continue
  62. callid = int(p["callid"])
  63. active.add(callid)
  64. raw = p.get("party_caller_id")
  65. is_external = p.get("party_dn_type") == "Wexternalline"
  66. e164 = self.normalize_phone(raw) if is_external else None
  67. now = now_iso()
  68. status_raw = str(p.get("status") or "").lower()
  69. participant_dn = str(
  70. p.get("partyDn") or p.get("party_dn") or ""
  71. ).strip()
  72. agent_dns_set = {
  73. str(x).strip()
  74. for x in (agent_dns or [])
  75. if str(x).strip()
  76. }
  77. if (
  78. status_raw in ("connected", "talking", "answered")
  79. and participant_dn in agent_dns_set
  80. ):
  81. current_status = "answered"
  82. else:
  83. current_status = "active"
  84. await db.execute("""
  85. INSERT INTO calls
  86. (callid, legid, phone, raw, queue_dn,
  87. first_seen_at, last_seen_at, status,
  88. direction, started_at, answered_at)
  89. VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
  90. ON CONFLICT(queue_dn, callid) DO UPDATE SET
  91. legid=excluded.legid,
  92. phone=excluded.phone,
  93. raw=excluded.raw,
  94. last_seen_at=excluded.last_seen_at,
  95. status=excluded.status,
  96. answered_at=CASE
  97. WHEN calls.answered_at IS NOT NULL
  98. THEN calls.answered_at
  99. WHEN excluded.status='answered'
  100. THEN excluded.last_seen_at
  101. ELSE NULL
  102. END
  103. """, (
  104. callid,
  105. p.get("legid"),
  106. e164,
  107. raw,
  108. queue_dn,
  109. now,
  110. now,
  111. current_status,
  112. "inbound" if is_external else "internal",
  113. now,
  114. now if current_status == "answered" else None,
  115. ))
  116. await db.commit()
  117. return active
  118. async def finalize_disappeared(self, queue_dn, active):
  119. async with aiosqlite.connect(self.path) as db:
  120. cur = await db.execute("""
  121. SELECT id, callid, phone, raw, last_seen_at
  122. FROM calls
  123. WHERE queue_dn=? AND status IN ('active', 'answered')
  124. """, (queue_dn,))
  125. for row_id, callid, phone, raw, last_seen_at in await cur.fetchall():
  126. if callid in active:
  127. continue
  128. last_seen = datetime.fromisoformat(last_seen_at)
  129. if datetime.now(timezone.utc) - last_seen < timedelta(seconds=10):
  130. continue
  131. now = now_iso()
  132. cur_state = await db.execute(
  133. "SELECT answered_at, started_at FROM calls WHERE id=?",
  134. (row_id,),
  135. )
  136. state_row = await cur_state.fetchone()
  137. answered_at = state_row[0] if state_row else None
  138. started_at = state_row[1] if state_row else None
  139. final_status = "ended" if answered_at else "missed"
  140. duration_seconds = None
  141. if started_at:
  142. started_dt = datetime.fromisoformat(started_at)
  143. ended_dt = datetime.fromisoformat(now)
  144. duration_seconds = max(
  145. 0,
  146. int((ended_dt - started_dt).total_seconds())
  147. )
  148. await db.execute(
  149. """
  150. UPDATE calls
  151. SET status=?,
  152. ended_at=?,
  153. last_seen_at=?,
  154. duration_seconds=?
  155. WHERE id=?
  156. """,
  157. (
  158. final_status,
  159. now,
  160. now,
  161. duration_seconds,
  162. row_id,
  163. ),
  164. )
  165. if final_status == "missed" and phone:
  166. await db.execute("""
  167. INSERT INTO missed_call_groups
  168. (phone, raw, first_call_at, last_call_at,
  169. attempt_count, status)
  170. VALUES (?, ?, ?, ?, 1, 'open')
  171. ON CONFLICT(phone) DO UPDATE SET
  172. raw=excluded.raw,
  173. last_call_at=excluded.last_call_at,
  174. attempt_count=missed_call_groups.attempt_count+1
  175. """, (phone, raw, last_seen_at, now))
  176. await db.commit()
  177. async def get_outbound_idempotency(self, key):
  178. async with aiosqlite.connect(self.path) as db:
  179. db.row_factory = aiosqlite.Row
  180. cur = await db.execute("""
  181. SELECT idempotency_key, request_hash, status, response_json, created_at
  182. FROM outbound_idempotency
  183. WHERE idempotency_key=?
  184. """, (key,))
  185. row = await cur.fetchone()
  186. return dict(row) if row else None
  187. async def claim_outbound_idempotency(self, key, request_hash, created_at):
  188. async with aiosqlite.connect(self.path) as db:
  189. try:
  190. await db.execute("BEGIN IMMEDIATE")
  191. cur = await db.execute("""
  192. INSERT OR IGNORE INTO outbound_idempotency
  193. (idempotency_key, request_hash, status, response_json, created_at)
  194. VALUES (?, ?, 'processing', NULL, ?)
  195. """, (key, request_hash, created_at))
  196. inserted = cur.rowcount == 1
  197. await db.commit()
  198. return inserted
  199. except Exception:
  200. await db.rollback()
  201. raise
  202. async def finish_outbound_idempotency(self, key, response_json):
  203. async with aiosqlite.connect(self.path) as db:
  204. await db.execute("""
  205. UPDATE outbound_idempotency
  206. SET status='completed', response_json=?
  207. WHERE idempotency_key=?
  208. """, (response_json, key))
  209. await db.commit()
  210. async def release_outbound_idempotency(self, key):
  211. async with aiosqlite.connect(self.path) as db:
  212. await db.execute("""
  213. DELETE FROM outbound_idempotency
  214. WHERE idempotency_key=? AND status='processing'
  215. """, (key,))
  216. await db.commit()
  217. async def insert_outbound_call(
  218. self,
  219. callid,
  220. legid,
  221. source_dn,
  222. destination,
  223. phone,
  224. raw,
  225. started_at,
  226. ):
  227. async with aiosqlite.connect(self.path) as db:
  228. await db.execute("""
  229. INSERT INTO calls (
  230. callid,
  231. legid,
  232. phone,
  233. queue_dn,
  234. first_seen_at,
  235. last_seen_at,
  236. status,
  237. raw,
  238. direction,
  239. started_at,
  240. source_dn,
  241. destination
  242. )
  243. VALUES (?, ?, ?, NULL, ?, ?, 'dialing', ?, 'outbound', ?, ?, ?)
  244. """, (
  245. callid,
  246. legid,
  247. phone,
  248. started_at,
  249. started_at,
  250. raw,
  251. started_at,
  252. source_dn,
  253. destination,
  254. ))
  255. await db.commit()
  256. async def list_active_outbound_calls(self):
  257. async with aiosqlite.connect(self.path) as db:
  258. db.row_factory = aiosqlite.Row
  259. cur = await db.execute("""
  260. SELECT
  261. id,
  262. callid,
  263. legid,
  264. source_dn,
  265. destination,
  266. status,
  267. started_at,
  268. answered_at,
  269. ended_at,
  270. duration_seconds
  271. FROM calls
  272. WHERE direction='outbound'
  273. AND ended_at IS NULL
  274. ORDER BY id
  275. """)
  276. return [dict(row) for row in await cur.fetchall()]
  277. async def update_outbound_connected(
  278. self,
  279. call_id,
  280. answered_at,
  281. last_seen_at,
  282. ):
  283. async with aiosqlite.connect(self.path) as db:
  284. await db.execute("""
  285. UPDATE calls
  286. SET
  287. status='connected',
  288. answered_at=COALESCE(answered_at, ?),
  289. last_seen_at=?
  290. WHERE id=?
  291. AND direction='outbound'
  292. AND ended_at IS NULL
  293. """, (
  294. answered_at,
  295. last_seen_at,
  296. call_id,
  297. ))
  298. await db.commit()
  299. async def update_outbound_seen(
  300. self,
  301. call_id,
  302. status,
  303. last_seen_at,
  304. ):
  305. async with aiosqlite.connect(self.path) as db:
  306. await db.execute("""
  307. UPDATE calls
  308. SET
  309. status=?,
  310. last_seen_at=?
  311. WHERE id=?
  312. AND direction='outbound'
  313. AND ended_at IS NULL
  314. """, (
  315. status,
  316. last_seen_at,
  317. call_id,
  318. ))
  319. await db.commit()
  320. async def finalize_outbound_call(
  321. self,
  322. call_id,
  323. ended_at,
  324. status,
  325. duration_seconds,
  326. ):
  327. async with aiosqlite.connect(self.path) as db:
  328. await db.execute("""
  329. UPDATE calls
  330. SET
  331. status=?,
  332. ended_at=?,
  333. duration_seconds=?,
  334. last_seen_at=?
  335. WHERE id=?
  336. AND direction='outbound'
  337. AND ended_at IS NULL
  338. """, (
  339. status,
  340. ended_at,
  341. duration_seconds,
  342. ended_at,
  343. call_id,
  344. ))
  345. await db.commit()
  346. async def get_call(self, call_id):
  347. async with aiosqlite.connect(self.path) as db:
  348. db.row_factory = aiosqlite.Row
  349. cur = await db.execute("""
  350. SELECT
  351. id,
  352. callid,
  353. legid,
  354. phone,
  355. raw,
  356. queue_dn,
  357. status,
  358. direction,
  359. first_seen_at,
  360. last_seen_at,
  361. started_at,
  362. answered_at,
  363. ended_at,
  364. duration_seconds,
  365. agent_dn,
  366. agent_name
  367. FROM calls
  368. WHERE id=?
  369. LIMIT 1
  370. """, (call_id,))
  371. row = await cur.fetchone()
  372. return dict(row) if row else None
  373. async def list_calls(self, limit=100, offset=0, status=None):
  374. async with aiosqlite.connect(self.path) as db:
  375. db.row_factory = aiosqlite.Row
  376. where = ""
  377. params = []
  378. if status:
  379. where = "WHERE status=?"
  380. params.append(status)
  381. cur = await db.execute(
  382. f"""
  383. SELECT
  384. id,
  385. callid,
  386. legid,
  387. phone,
  388. raw,
  389. queue_dn,
  390. status,
  391. direction,
  392. first_seen_at,
  393. last_seen_at,
  394. started_at,
  395. answered_at,
  396. ended_at,
  397. duration_seconds,
  398. agent_dn,
  399. agent_name
  400. FROM calls
  401. {where}
  402. ORDER BY COALESCE(started_at, first_seen_at) DESC
  403. LIMIT ? OFFSET ?
  404. """,
  405. (*params, limit, offset),
  406. )
  407. rows = [dict(row) for row in await cur.fetchall()]
  408. cur = await db.execute(
  409. f"SELECT COUNT(*) FROM calls {where}",
  410. tuple(params),
  411. )
  412. total = (await cur.fetchone())[0]
  413. return rows, total
  414. def _decorate(self, row):
  415. last_call = row["last_call_at"]
  416. resolved_until = row["resolved_until"]
  417. row["e164"] = row["phone"]
  418. row["status"] = (
  419. "resolved"
  420. if resolved_until and resolved_until >= last_call
  421. else "open"
  422. )
  423. return row
  424. async def list_groups(self, open_only=False):
  425. async with aiosqlite.connect(self.path) as db:
  426. db.row_factory = aiosqlite.Row
  427. cur = await db.execute(
  428. "SELECT * FROM missed_call_groups ORDER BY last_call_at DESC"
  429. )
  430. rows = [dict(x) for x in await cur.fetchall()]
  431. rows = [self._decorate(x) for x in rows]
  432. if open_only:
  433. rows = [x for x in rows if x["status"] == "open"]
  434. return rows
  435. async def get_group(self, e164):
  436. async with aiosqlite.connect(self.path) as db:
  437. db.row_factory = aiosqlite.Row
  438. cur = await db.execute(
  439. "SELECT * FROM missed_call_groups WHERE phone=?",
  440. (e164,),
  441. )
  442. row = await cur.fetchone()
  443. return self._decorate(dict(row)) if row else None
  444. async def get_idempotency(self, key, operation):
  445. async with aiosqlite.connect(self.path) as db:
  446. cur = await db.execute(
  447. """SELECT response_json
  448. FROM idempotency_keys
  449. WHERE idem_key=? AND operation=?""",
  450. (key, operation),
  451. )
  452. row = await cur.fetchone()
  453. return row[0] if row else None
  454. async def save_idempotency(self, key, operation, response_json):
  455. async with aiosqlite.connect(self.path) as db:
  456. await db.execute("""
  457. INSERT OR IGNORE INTO idempotency_keys
  458. (idem_key, operation, response_json, created_at)
  459. VALUES (?, ?, ?, ?)
  460. """, (key, operation, response_json, now_iso()))
  461. await db.commit()
  462. async def resolve(self, e164, until, by, note):
  463. async with aiosqlite.connect(self.path) as db:
  464. await db.execute("""
  465. UPDATE missed_call_groups
  466. SET resolved_until=?,
  467. resolved_by=?,
  468. resolved_at=?,
  469. resolved_note=?
  470. WHERE phone=?
  471. """, (until, by, now_iso(), note, e164))
  472. await db.commit()
  473. return await self.get_group(e164)
  474. async def reopen(self, e164, by):
  475. async with aiosqlite.connect(self.path) as db:
  476. await db.execute("""
  477. UPDATE missed_call_groups
  478. SET resolved_until=NULL,
  479. resolved_by=?,
  480. resolved_at=?,
  481. resolved_note=NULL
  482. WHERE phone=?
  483. """, (by, now_iso(), e164))
  484. await db.commit()
  485. return await self.get_group(e164)