176 lines
5.3 KiB
Python
176 lines
5.3 KiB
Python
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,
|
|
)
|
|
)
|