from __future__ import annotations from dataclasses import dataclass from django.db import transaction from core.models import ( ContactAvailabilityEvent, ContactAvailabilitySettings, ContactAvailabilitySpan, Person, PersonIdentifier, User, ) from .inference import fade_confidence, now_ms, should_fade POSITIVE_SOURCE_KINDS = { "native_presence", "read_receipt", "typing_start", "message_in", } @dataclass(slots=True) class AvailabilitySignal: user: User person: Person person_identifier: PersonIdentifier | None service: str source_kind: str availability_state: str confidence: float = 0.8 ts: int = 0 payload: dict | None = None def get_settings(user: User) -> ContactAvailabilitySettings: settings_row, _ = ContactAvailabilitySettings.objects.get_or_create(user=user) return settings_row def _normalize_ts(value: int | None) -> int: try: ts = int(value or 0) except Exception: ts = 0 return ts if ts > 0 else now_ms() def _upsert_spans_for_event(event: ContactAvailabilityEvent) -> None: prev = ( ContactAvailabilitySpan.objects.filter( user=event.user, person=event.person, service=event.service, ) .order_by("-end_ts", "-id") .first() ) if prev and prev.state == event.availability_state: prev.end_ts = max(int(prev.end_ts or 0), int(event.ts or 0)) prev.confidence_end = float(event.confidence or 0.0) prev.closing_event = event prev.payload = dict(prev.payload or {}) prev.payload.update({"extended_by": str(event.source_kind or "")}) prev.save( update_fields=[ "end_ts", "confidence_end", "closing_event", "payload", "updated_at", ] ) return ContactAvailabilitySpan.objects.create( user=event.user, person=event.person, person_identifier=event.person_identifier, service=event.service, state=event.availability_state, start_ts=int(event.ts or 0), end_ts=int(event.ts or 0), confidence_start=float(event.confidence or 0.0), confidence_end=float(event.confidence or 0.0), opening_event=event, closing_event=event, payload=dict(event.payload or {}), ) @transaction.atomic def record_native_signal(signal: AvailabilitySignal) -> ContactAvailabilityEvent | None: settings_row = get_settings(signal.user) if not settings_row.enabled: return None event = ContactAvailabilityEvent.objects.create( user=signal.user, person=signal.person, person_identifier=signal.person_identifier, service=str(signal.service or "").strip().lower() or "signal", source_kind=str(signal.source_kind or "").strip() or "native_presence", availability_state=str(signal.availability_state or "unknown").strip() or "unknown", confidence=float(signal.confidence or 0.0), ts=_normalize_ts(signal.ts), payload=dict(signal.payload or {}), ) _upsert_spans_for_event(event) _prune_old_data(signal.user, settings_row.retention_days) return event def record_inferred_signal( signal: AvailabilitySignal, ) -> ContactAvailabilityEvent | None: settings_row = get_settings(signal.user) if not settings_row.enabled or not settings_row.inference_enabled: return None return record_native_signal(signal) def _prune_old_data(user: User, retention_days: int) -> None: days = max(1, int(retention_days or 90)) cutoff = now_ms() - (days * 24 * 60 * 60 * 1000) ContactAvailabilityEvent.objects.filter(user=user, ts__lt=cutoff).delete() ContactAvailabilitySpan.objects.filter(user=user, end_ts__lt=cutoff).delete() def ensure_fading_state( *, user: User, person: Person, person_identifier: PersonIdentifier | None, service: str, at_ts: int | None = None, ) -> ContactAvailabilityEvent | None: settings_row = get_settings(user) if not settings_row.enabled or not settings_row.inference_enabled: return None current_ts = _normalize_ts(at_ts) latest = ( ContactAvailabilityEvent.objects.filter( user=user, person=person, service=str(service or "").strip().lower(), ) .order_by("-ts", "-id") .first() ) if latest is None: return None if latest.availability_state in {"fading", "unavailable"}: return None if latest.source_kind not in POSITIVE_SOURCE_KINDS: return None if not should_fade( int(latest.ts or 0), current_ts, settings_row.fade_threshold_seconds ): return None elapsed = max(0, current_ts - int(latest.ts or 0)) payload = { "inferred_from": latest.source_kind, "last_signal_ts": int(latest.ts or 0), "elapsed_ms": elapsed, } return record_inferred_signal( AvailabilitySignal( user=user, person=person, person_identifier=person_identifier, service=service, source_kind="inferred_timeout", availability_state="fading", confidence=fade_confidence(elapsed, settings_row.fade_threshold_seconds), ts=current_ts, payload=payload, ) )