from __future__ import annotations from django.core.management.base import BaseCommand from core.models import ContactAvailabilityEvent, ContactAvailabilitySpan, Message from core.presence import AvailabilitySignal, record_native_signal from core.presence.inference import now_ms _SOURCE_ORDER = { "message_in": 10, "message_out": 20, "read_receipt": 30, "native_presence": 40, } class Command(BaseCommand): help = ( "Recalculate contact availability events/spans from persisted message, " "read-receipt, and reaction history (deterministic rebuild)." ) def add_arguments(self, parser): parser.add_argument("--days", type=int, default=90) parser.add_argument("--limit", type=int, default=20000) parser.add_argument("--service", default="") parser.add_argument("--user-id", default="") parser.add_argument("--dry-run", action="store_true", default=False) parser.add_argument("--no-reset", action="store_true", default=False) def _iter_messages(self, *, days: int, limit: int, service: str, user_id: str): cutoff_ts = now_ms() - (max(1, int(days)) * 24 * 60 * 60 * 1000) qs = Message.objects.filter(ts__gte=cutoff_ts).select_related( "user", "session", "session__identifier", "session__identifier__person" ) if service: qs = qs.filter(source_service=str(service).strip().lower()) if user_id: qs = qs.filter(user_id=str(user_id).strip()) return qs.order_by("ts")[: max(1, int(limit))] def _build_event_rows(self, messages): rows = [] for msg in messages: identifier = getattr(getattr(msg, "session", None), "identifier", None) person = getattr(identifier, "person", None) user = getattr(msg, "user", None) if not identifier or not person or not user: continue service = ( str( getattr(msg, "source_service", "") or getattr(identifier, "service", "") ) .strip() .lower() ) if not service: continue ts = int(getattr(msg, "ts", 0) or 0) if ts > 0: author = str(getattr(msg, "custom_author", "") or "").strip().upper() outgoing = author in {"USER", "BOT"} rows.append( { "user": user, "person": person, "person_identifier": identifier, "service": service, "source_kind": "message_out" if outgoing else "message_in", "availability_state": "available", "confidence": 0.65 if outgoing else 0.75, "ts": ts, "payload": { "origin": "recalculate_contact_availability", "message_id": str(msg.id), "inferred_from": "message_activity", }, } ) read_ts = int(getattr(msg, "read_ts", 0) or 0) if read_ts > 0: rows.append( { "user": user, "person": person, "person_identifier": identifier, "service": service, "source_kind": "read_receipt", "availability_state": "available", "confidence": 0.95, "ts": read_ts, "payload": { "origin": "recalculate_contact_availability", "message_id": str(msg.id), "inferred_from": "read_receipt", "read_by": str( getattr(msg, "read_by_identifier", "") or "" ), }, } ) reactions = list( (getattr(msg, "receipt_payload", {}) or {}).get("reactions") or [] ) for reaction in reactions: item = dict(reaction or {}) if bool(item.get("removed")): continue reaction_ts = int(item.get("updated_at") or 0) if reaction_ts <= 0: continue rows.append( { "user": user, "person": person, "person_identifier": identifier, "service": service, "source_kind": "native_presence", "availability_state": "available", "confidence": 0.9, "ts": reaction_ts, "payload": { "origin": "recalculate_contact_availability", "message_id": str(msg.id), "inferred_from": "reaction", "emoji": str(item.get("emoji") or ""), "actor": str(item.get("actor") or ""), "source_service": str( item.get("source_service") or service ), }, } ) rows.sort( key=lambda row: ( str(getattr(row["user"], "id", "")), str(getattr(row["person"], "id", "")), str(row.get("service") or ""), int(row.get("ts") or 0), _SOURCE_ORDER.get(str(row.get("source_kind") or ""), 999), str((row.get("payload") or {}).get("message_id") or ""), ) ) return rows def handle(self, *args, **options): days = max(1, int(options.get("days") or 90)) limit = max(1, int(options.get("limit") or 20000)) service_filter = str(options.get("service") or "").strip().lower() user_filter = str(options.get("user_id") or "").strip() dry_run = bool(options.get("dry_run")) reset = not bool(options.get("no_reset")) cutoff_ts = now_ms() - (days * 24 * 60 * 60 * 1000) messages = list( self._iter_messages( days=days, limit=limit, service=service_filter, user_id=user_filter, ) ) rows = self._build_event_rows(messages) keys_to_reset = set() for row in rows: keys_to_reset.add( ( str(getattr(row["user"], "id", "")), str(getattr(row["person"], "id", "")), str(row.get("service") or ""), ) ) deleted_events = 0 deleted_spans = 0 if reset and keys_to_reset and not dry_run: for user_id, person_id, service in keys_to_reset: deleted_events += ContactAvailabilityEvent.objects.filter( user_id=user_id, person_id=person_id, service=service, ts__gte=cutoff_ts, ).delete()[0] deleted_spans += ContactAvailabilitySpan.objects.filter( user_id=user_id, person_id=person_id, service=service, end_ts__gte=cutoff_ts, ).delete()[0] created = 0 dedup_seen = set() for row in rows: dedup_key = ( str(getattr(row["user"], "id", "")), str(getattr(row["person"], "id", "")), str(getattr(row["person_identifier"], "id", "")), str(row.get("service") or ""), str(row.get("source_kind") or ""), int(row.get("ts") or 0), str((row.get("payload") or {}).get("message_id") or ""), str((row.get("payload") or {}).get("inferred_from") or ""), ) if dedup_key in dedup_seen: continue dedup_seen.add(dedup_key) if not reset: exists = ContactAvailabilityEvent.objects.filter( user=row["user"], person=row["person"], person_identifier=row["person_identifier"], service=row["service"], source_kind=row["source_kind"], ts=row["ts"], ).exists() if exists: continue created += 1 if dry_run: continue record_native_signal( AvailabilitySignal( user=row["user"], person=row["person"], person_identifier=row["person_identifier"], service=row["service"], source_kind=row["source_kind"], availability_state=row["availability_state"], confidence=float(row["confidence"]), ts=int(row["ts"]), payload=dict(row["payload"]), ) ) self.stdout.write( self.style.SUCCESS( "recalculate_contact_availability complete " f"messages_scanned={len(messages)} candidates={len(rows)} " f"created={created} deleted_events={deleted_events} deleted_spans={deleted_spans} " f"reset={reset} dry_run={dry_run} days={days} limit={limit}" ) )