Files
2026-04-05 20:26:46 +05:00

250 lines
8.9 KiB
Python

from __future__ import annotations
from datetime import datetime, timezone
from sqlalchemy import select
from services.shared.db import get_session
from services.shared.sql_models import (
AsteriskCallLinkRow,
CallRecordingRow,
VoiceAISessionRow,
VoiceEventRow,
)
def _bridge_app():
import services.asterisk_bridge_service.app as bridge_app
return bridge_app
def parse_iso(value: str | None) -> datetime | None:
text = str(value or "").strip()
if not text:
return None
try:
parsed = datetime.fromisoformat(text)
except ValueError:
return None
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed.astimezone(timezone.utc)
def has_voice_event(session, *, call_id: str, event_type: str) -> bool:
row = session.execute(
select(VoiceEventRow.id)
.where(VoiceEventRow.call_id == call_id)
.where(VoiceEventRow.event_type == event_type)
.limit(1)
).scalar_one_or_none()
return row is not None
def has_recording(session, *, call_id: str) -> bool:
row = session.execute(
select(CallRecordingRow.id).where(CallRecordingRow.call_id == call_id).limit(1)
).scalar_one_or_none()
return row is not None
def latest_recording_for_call(session, *, call_id: str) -> CallRecordingRow | None:
return session.execute(
select(CallRecordingRow)
.where(CallRecordingRow.call_id == call_id)
.order_by(CallRecordingRow.id.desc())
.limit(1)
).scalar_one_or_none()
def _voice_session_recently_active(session, *, link: AsteriskCallLinkRow, now: datetime) -> bool:
bridge = _bridge_app()
non_terminal_states = {
"queued",
"greeting",
"active",
"thinking",
"speaking",
"listening",
"handoff_requested",
"handoff_required",
}
link_ai_state = str(link.ai_state or "").strip()
voice_session_id = str(link.voice_session_id or "").strip()
if not voice_session_id and link_ai_state not in non_terminal_states:
return False
voice_session = session.execute(
select(VoiceAISessionRow).where(VoiceAISessionRow.session_id == voice_session_id)
).scalar_one_or_none()
if voice_session is None:
return link_ai_state in non_terminal_states
effective_ai_state = link_ai_state or str(voice_session.status or "").strip()
if effective_ai_state not in non_terminal_states:
return False
recent_points = [
bridge._parse_iso(voice_session.last_media_frame_at),
bridge._parse_iso(voice_session.last_user_utterance_at),
bridge._parse_iso(voice_session.last_ai_reply_at),
bridge._parse_iso(voice_session.updated_at),
]
recent_points = [point for point in recent_points if point is not None]
if not recent_points:
return False
latest_point = max(recent_points)
grace_seconds = max(bridge._reconcile_stale_seconds(), 12) + 8
return max((now - latest_point).total_seconds(), 0) <= grace_seconds
def reconcile_stale_calls_once() -> None:
bridge = _bridge_app()
now = datetime.now(timezone.utc)
session = get_session()
try:
unresolved_recording = (
select(CallRecordingRow.id)
.where(CallRecordingRow.call_id == AsteriskCallLinkRow.call_id)
.correlate(AsteriskCallLinkRow)
.exists()
)
active_ids = session.execute(
select(AsteriskCallLinkRow.id)
.where(AsteriskCallLinkRow.status == "active")
.order_by(AsteriskCallLinkRow.id.asc())
.limit(bridge._reconcile_scan_limit())
).scalars().all()
ended_ids = session.execute(
select(AsteriskCallLinkRow.id)
.where(AsteriskCallLinkRow.status == "ended")
.where(~unresolved_recording)
.order_by(AsteriskCallLinkRow.id.asc())
.limit(bridge._reconcile_scan_limit())
).scalars().all()
finally:
session.close()
for link_id in active_ids:
session = get_session()
try:
link = session.execute(
select(AsteriskCallLinkRow).where(AsteriskCallLinkRow.id == link_id)
).scalar_one_or_none()
if link is None:
continue
started_dt = bridge._parse_iso(link.started_at) or bridge._parse_iso(link.updated_at) or now
age_seconds = max((now - started_dt).total_seconds(), 0)
if age_seconds < bridge._reconcile_stale_seconds():
continue
live_channels = bridge._list_channels_via_coreshowchannels(
call_id=link.call_id,
linked_id=link.linked_id,
)
if live_channels is None:
continue
if live_channels:
preferred_channel = (
bridge._operator_channel_for_extension(live_channels, link.operator_extension)
or live_channels[0]
)
if preferred_channel != link.channel_name:
link.channel_name = preferred_channel
link.updated_at = bridge.utc_now_iso()
session.commit()
continue
if _voice_session_recently_active(session, link=link, now=now):
link.updated_at = bridge.utc_now_iso()
session.commit()
continue
ended_exists = bridge._has_voice_event(session, call_id=link.call_id, event_type="call.ended")
if not ended_exists:
bridge._emit_voice_event(
event_type="call.ended",
call_id=link.call_id,
interaction_id=link.interaction_id,
source_event_id=bridge._reconcile_source_event_id(link.call_id, "call.ended"),
payload={
"source": "asterisk",
"hangup_cause": "unknown",
"duration_seconds": None,
"linked_id": link.linked_id,
"reconciled": True,
},
)
now_iso = bridge.utc_now_iso()
link.status = "ended"
link.telephony_status = "ended"
link.ended_at = link.ended_at or now_iso
link.updated_at = now_iso
session.commit()
except Exception:
session.rollback()
continue
finally:
session.close()
for link_id in ended_ids:
session = get_session()
try:
link = session.execute(
select(AsteriskCallLinkRow).where(AsteriskCallLinkRow.id == link_id)
).scalar_one_or_none()
if link is None:
continue
ready_exists = bridge._has_voice_event(session, call_id=link.call_id, event_type="recording.ready")
recording_exists = bridge._has_recording(session, call_id=link.call_id)
if recording_exists:
continue
candidate = bridge._find_remote_recording_for_call(link.call_id)
if candidate is None:
continue
remote_path, file_name, mime_type, mtime_dt = candidate
idle_seconds = max((now - mtime_dt).total_seconds(), 0)
if idle_seconds < bridge._reconcile_settle_seconds():
continue
if not ready_exists:
bridge._emit_voice_event(
event_type="recording.ready",
call_id=link.call_id,
interaction_id=link.interaction_id,
source_event_id=bridge._reconcile_source_event_id(link.call_id, "recording.ready"),
payload={
"source": "asterisk",
"remote_path": remote_path,
"file_name": file_name,
"mime_type": mime_type,
"duration_seconds": None,
"linked_id": link.linked_id,
"reconciled": True,
},
)
local_path, cleanup_required = bridge._fetch_recording_file(remote_path, file_name)
try:
bridge._upload_recording(
local_path=local_path,
call_id=link.call_id,
interaction_id=link.interaction_id,
source_event_id=bridge._reconcile_source_event_id(link.call_id, "recording.import"),
file_name=file_name,
mime_type=mime_type,
duration_seconds=None,
)
finally:
if cleanup_required and local_path.exists():
local_path.unlink(missing_ok=True)
link.updated_at = bridge.utc_now_iso()
session.commit()
except Exception:
session.rollback()
continue
finally:
session.close()