252 lines
9.7 KiB
Python
252 lines
9.7 KiB
Python
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}"
|
|
)
|
|
)
|