Implement executing tasks
This commit is contained in:
@@ -23,6 +23,7 @@ from two_factor.urls import urlpatterns as tf_urls
|
||||
from core.views import (
|
||||
ais,
|
||||
automation,
|
||||
availability,
|
||||
base,
|
||||
compose,
|
||||
groups,
|
||||
@@ -307,6 +308,11 @@ urlpatterns = [
|
||||
tasks.TaskSettings.as_view(),
|
||||
name="tasks_settings",
|
||||
),
|
||||
path(
|
||||
"settings/availability/",
|
||||
availability.AvailabilitySettingsPage.as_view(),
|
||||
name="availability_settings",
|
||||
),
|
||||
# AIs
|
||||
path(
|
||||
"ai/workspace/",
|
||||
|
||||
@@ -193,6 +193,28 @@ def _extract_signal_reaction(envelope):
|
||||
}
|
||||
|
||||
|
||||
def _extract_signal_text(raw_payload, default_text=""):
|
||||
text = str(default_text or "").strip()
|
||||
if text:
|
||||
return text
|
||||
payload = dict(raw_payload or {})
|
||||
envelope = dict(payload.get("envelope") or {})
|
||||
candidates = [
|
||||
envelope.get("dataMessage"),
|
||||
_get_nested(envelope, ("syncMessage", "sentMessage", "message")),
|
||||
_get_nested(envelope, ("syncMessage", "sentMessage")),
|
||||
payload.get("dataMessage"),
|
||||
payload,
|
||||
]
|
||||
for item in candidates:
|
||||
if isinstance(item, dict):
|
||||
for key in ("message", "text", "body", "caption"):
|
||||
value = str(item.get(key) or "").strip()
|
||||
if value:
|
||||
return value
|
||||
return ""
|
||||
|
||||
|
||||
def _typing_started(typing_payload):
|
||||
action = str(typing_payload.get("action") or "").strip().lower()
|
||||
if action in {"started", "start", "typing", "composing"}:
|
||||
@@ -368,6 +390,7 @@ class HandleMessage(Command):
|
||||
source_number = c.message.source_number
|
||||
source_uuid = c.message.source_uuid
|
||||
text = c.message.text
|
||||
text = _extract_signal_text(raw, text)
|
||||
ts = c.message.timestamp
|
||||
source_value = c.message.source
|
||||
envelope = raw.get("envelope", {})
|
||||
@@ -1209,14 +1232,17 @@ class SignalClient(ClientBase):
|
||||
if isinstance(sync_sent_message, dict) and sync_sent_message:
|
||||
raw_text = sync_sent_message.get("message")
|
||||
if isinstance(raw_text, dict):
|
||||
text = str(
|
||||
text = _extract_signal_text(
|
||||
{"envelope": {"syncMessage": {"sentMessage": {"message": raw_text}}}},
|
||||
str(
|
||||
raw_text.get("message")
|
||||
or raw_text.get("text")
|
||||
or raw_text.get("body")
|
||||
or ""
|
||||
).strip()
|
||||
).strip(),
|
||||
)
|
||||
else:
|
||||
text = str(raw_text or "").strip()
|
||||
text = _extract_signal_text(payload, str(raw_text or "").strip())
|
||||
|
||||
destination_uuid = str(
|
||||
sync_sent_message.get("destinationUuid")
|
||||
@@ -1373,7 +1399,7 @@ class SignalClient(ClientBase):
|
||||
)
|
||||
return
|
||||
|
||||
text = str(data_message.get("message") or "").strip()
|
||||
text = _extract_signal_text(payload, str(data_message.get("message") or "").strip())
|
||||
if not text:
|
||||
return
|
||||
|
||||
|
||||
@@ -2355,8 +2355,29 @@ class WhatsAppClient(ClientBase):
|
||||
return "application/octet-stream"
|
||||
|
||||
def _extract_reaction_event(self, message_obj):
|
||||
node = self._pluck(message_obj, "reactionMessage") or self._pluck(
|
||||
message_obj, "reaction_message"
|
||||
node = (
|
||||
self._pluck(message_obj, "reactionMessage")
|
||||
or self._pluck(message_obj, "reaction_message")
|
||||
or self._pluck(message_obj, "ephemeralMessage", "message", "reactionMessage")
|
||||
or self._pluck(message_obj, "ephemeral_message", "message", "reaction_message")
|
||||
or self._pluck(message_obj, "viewOnceMessage", "message", "reactionMessage")
|
||||
or self._pluck(message_obj, "view_once_message", "message", "reaction_message")
|
||||
or self._pluck(message_obj, "viewOnceMessageV2", "message", "reactionMessage")
|
||||
or self._pluck(message_obj, "view_once_message_v2", "message", "reaction_message")
|
||||
or self._pluck(
|
||||
message_obj,
|
||||
"viewOnceMessageV2Extension",
|
||||
"message",
|
||||
"reactionMessage",
|
||||
)
|
||||
or self._pluck(
|
||||
message_obj,
|
||||
"view_once_message_v2_extension",
|
||||
"message",
|
||||
"reaction_message",
|
||||
)
|
||||
or self._pluck(message_obj, "protocolMessage", "reactionMessage")
|
||||
or self._pluck(message_obj, "protocol_message", "reaction_message")
|
||||
)
|
||||
if not node:
|
||||
return None
|
||||
@@ -2366,17 +2387,34 @@ class WhatsAppClient(ClientBase):
|
||||
target_msg_id = str(
|
||||
self._pluck(node, "key", "id")
|
||||
or self._pluck(node, "key", "ID")
|
||||
or self._pluck(node, "messageKey", "id")
|
||||
or self._pluck(node, "message_key", "id")
|
||||
or self._pluck(node, "targetMessageKey", "id")
|
||||
or self._pluck(node, "target_message_key", "id")
|
||||
or self._pluck(node, "stanzaId")
|
||||
or self._pluck(node, "stanza_id")
|
||||
or ""
|
||||
).strip()
|
||||
remove = bool(not emoji)
|
||||
target_ts = self._normalize_timestamp(
|
||||
self._pluck(node, "key", "messageTimestamp")
|
||||
or self._pluck(node, "targetMessageKey", "messageTimestamp")
|
||||
or self._pluck(node, "target_message_key", "message_timestamp")
|
||||
or self._pluck(node, "targetTimestamp")
|
||||
or self._pluck(node, "target_timestamp")
|
||||
or 0
|
||||
)
|
||||
explicit_remove = self._pluck(node, "remove") or self._pluck(node, "isRemove")
|
||||
if explicit_remove is None:
|
||||
explicit_remove = self._pluck(node, "is_remove")
|
||||
remove = bool(explicit_remove) if explicit_remove is not None else bool(not emoji)
|
||||
if not target_msg_id:
|
||||
return None
|
||||
return {
|
||||
"emoji": emoji,
|
||||
"target_message_id": target_msg_id,
|
||||
"remove": remove,
|
||||
"target_ts": int(target_ts or 0),
|
||||
"raw": self._proto_to_dict(node) or dict(node or {}) if isinstance(node, dict) else {},
|
||||
}
|
||||
|
||||
async def _download_event_media(self, event):
|
||||
@@ -2438,6 +2476,10 @@ class WhatsAppClient(ClientBase):
|
||||
async def _handle_message_event(self, event):
|
||||
event_obj = self._proto_to_dict(event) or event
|
||||
msg_obj = self._pluck(event_obj, "message") or self._pluck(event_obj, "Message")
|
||||
if self._pluck(msg_obj, "protocolMessage") or self._pluck(
|
||||
msg_obj, "protocol_message"
|
||||
):
|
||||
return
|
||||
text = self._message_text(msg_obj, event_obj)
|
||||
if not text:
|
||||
self.log.debug(
|
||||
@@ -2482,7 +2524,7 @@ class WhatsAppClient(ClientBase):
|
||||
).strip()
|
||||
ts = self._normalize_timestamp(raw_ts)
|
||||
|
||||
reaction_payload = self._extract_reaction_event(msg_obj)
|
||||
reaction_payload = self._extract_reaction_event(msg_obj or event_obj)
|
||||
if reaction_payload:
|
||||
self.log.debug(
|
||||
"reaction-bridge whatsapp-inbound msg_id=%s target_id=%s emoji=%s remove=%s sender=%s chat=%s",
|
||||
@@ -2508,6 +2550,26 @@ class WhatsAppClient(ClientBase):
|
||||
)
|
||||
)
|
||||
for identifier in identifiers:
|
||||
try:
|
||||
await history.apply_reaction(
|
||||
identifier.user,
|
||||
identifier,
|
||||
target_message_id=str(
|
||||
reaction_payload.get("target_message_id") or ""
|
||||
),
|
||||
target_ts=int(reaction_payload.get("target_ts") or 0),
|
||||
emoji=str(reaction_payload.get("emoji") or ""),
|
||||
source_service="whatsapp",
|
||||
actor=str(sender or chat or ""),
|
||||
remove=bool(reaction_payload.get("remove")),
|
||||
payload={
|
||||
"event": "reaction",
|
||||
"message_id": msg_id,
|
||||
"raw": reaction_payload.get("raw") or {},
|
||||
},
|
||||
)
|
||||
except Exception as exc:
|
||||
self.log.warning("whatsapp reaction local apply failed: %s", exc)
|
||||
try:
|
||||
await self.ur.xmpp.client.apply_external_reaction(
|
||||
identifier.user,
|
||||
@@ -2527,6 +2589,21 @@ class WhatsAppClient(ClientBase):
|
||||
)
|
||||
except Exception as exc:
|
||||
self.log.warning("whatsapp reaction relay to XMPP failed: %s", exc)
|
||||
try:
|
||||
await self.ur.presence_changed(
|
||||
self.service,
|
||||
identifier=identifier.identifier,
|
||||
state="available",
|
||||
confidence=0.9,
|
||||
ts=int(ts or int(time.time() * 1000)),
|
||||
payload={
|
||||
"event": "reaction",
|
||||
"inferred_from": "reaction",
|
||||
"message_id": msg_id,
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
self._remember_contact(
|
||||
@@ -2907,14 +2984,42 @@ class WhatsAppClient(ClientBase):
|
||||
is_unavailable = bool(
|
||||
self._pluck(event, "Unavailable") or self._pluck(event, "unavailable")
|
||||
)
|
||||
last_seen_raw = (
|
||||
self._pluck(event, "LastSeen")
|
||||
or self._pluck(event, "lastSeen")
|
||||
or self._pluck(event, "last_seen")
|
||||
or self._pluck(event, "Timestamp")
|
||||
or self._pluck(event, "timestamp")
|
||||
or 0
|
||||
)
|
||||
last_seen_ts = self._normalize_timestamp(last_seen_raw)
|
||||
self._remember_contact(sender, jid=sender)
|
||||
|
||||
for candidate in self._normalize_identifier_candidates(sender):
|
||||
try:
|
||||
await self.ur.presence_changed(
|
||||
self.service,
|
||||
identifier=candidate,
|
||||
state=("unavailable" if is_unavailable else "available"),
|
||||
confidence=0.9 if not is_unavailable else 0.8,
|
||||
ts=int(last_seen_ts or int(time.time() * 1000)),
|
||||
payload={
|
||||
"presence": ("offline" if is_unavailable else "online"),
|
||||
"sender": str(sender),
|
||||
"last_seen_ts": int(last_seen_ts or 0),
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
if is_unavailable:
|
||||
await self.ur.stopped_typing(
|
||||
self.service,
|
||||
identifier=candidate,
|
||||
payload={"presence": "offline", "sender": str(sender)},
|
||||
payload={
|
||||
"presence": "offline",
|
||||
"sender": str(sender),
|
||||
"last_seen_ts": int(last_seen_ts or 0),
|
||||
},
|
||||
)
|
||||
|
||||
def _extract_pair_qr(self, event):
|
||||
|
||||
123
core/management/commands/backfill_contact_availability.py
Normal file
123
core/management/commands/backfill_contact_availability.py
Normal file
@@ -0,0 +1,123 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Iterable
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from core.models import Message, User
|
||||
from core.presence import AvailabilitySignal, record_inferred_signal
|
||||
from core.presence.inference import now_ms
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Backfill inferred contact availability events from historical message/read-receipt activity."
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument("--days", type=int, default=30)
|
||||
parser.add_argument("--limit", type=int, default=5000)
|
||||
parser.add_argument("--service", default="")
|
||||
parser.add_argument("--user-id", default="")
|
||||
parser.add_argument("--dry-run", action="store_true", default=False)
|
||||
|
||||
def _iter_messages(self, *, days: int, limit: int, service: str, user_id: str) -> Iterable[Message]:
|
||||
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 handle(self, *args, **options):
|
||||
days = max(1, int(options.get("days") or 30))
|
||||
limit = max(1, int(options.get("limit") or 5000))
|
||||
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"))
|
||||
|
||||
created = 0
|
||||
scanned = 0
|
||||
|
||||
for msg in self._iter_messages(days=days, limit=limit, service=service_filter, user_id=user_filter):
|
||||
scanned += 1
|
||||
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 identifier.service or "").strip().lower()
|
||||
if not service:
|
||||
continue
|
||||
|
||||
base_ts = int(getattr(msg, "ts", 0) or 0)
|
||||
message_author = str(getattr(msg, "custom_author", "") or "").strip().upper()
|
||||
outgoing = message_author in {"USER", "BOT"}
|
||||
|
||||
candidates = []
|
||||
if base_ts > 0:
|
||||
candidates.append(
|
||||
{
|
||||
"source_kind": "message_out" if outgoing else "message_in",
|
||||
"availability_state": "available",
|
||||
"confidence": 0.65 if outgoing else 0.75,
|
||||
"ts": base_ts,
|
||||
"payload": {
|
||||
"origin": "backfill_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:
|
||||
candidates.append(
|
||||
{
|
||||
"source_kind": "read_receipt",
|
||||
"availability_state": "available",
|
||||
"confidence": 0.95,
|
||||
"ts": read_ts,
|
||||
"payload": {
|
||||
"origin": "backfill_contact_availability",
|
||||
"message_id": str(msg.id),
|
||||
"inferred_from": "read_receipt",
|
||||
"read_by": str(getattr(msg, "read_by_identifier", "") or ""),
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
for row in candidates:
|
||||
exists = user.contact_availability_events.filter(
|
||||
person=person,
|
||||
person_identifier=identifier,
|
||||
service=service,
|
||||
source_kind=row["source_kind"],
|
||||
ts=int(row["ts"]),
|
||||
).exists()
|
||||
if exists:
|
||||
continue
|
||||
created += 1
|
||||
if dry_run:
|
||||
continue
|
||||
record_inferred_signal(
|
||||
AvailabilitySignal(
|
||||
user=user,
|
||||
person=person,
|
||||
person_identifier=identifier,
|
||||
service=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(
|
||||
f"backfill_contact_availability complete scanned={scanned} created={created} dry_run={dry_run} days={days} limit={limit}"
|
||||
)
|
||||
)
|
||||
127
core/management/commands/codex_worker.py
Normal file
127
core/management/commands/codex_worker.py
Normal file
@@ -0,0 +1,127 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from core.models import ExternalSyncEvent, TaskProviderConfig
|
||||
from core.tasks.providers import get_provider
|
||||
from core.util import logs
|
||||
|
||||
log = logs.get_logger("codex_worker")
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Process queued external sync events for worker-backed providers (codex_cli)."
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument("--once", action="store_true", default=False)
|
||||
parser.add_argument("--sleep-seconds", type=float, default=2.0)
|
||||
parser.add_argument("--batch-size", type=int, default=20)
|
||||
parser.add_argument("--provider", default="codex_cli")
|
||||
|
||||
def _claim_batch(self, provider: str, batch_size: int) -> list[str]:
|
||||
ids: list[str] = []
|
||||
rows = list(
|
||||
ExternalSyncEvent.objects.filter(
|
||||
provider=provider,
|
||||
status__in=["pending", "retrying"],
|
||||
)
|
||||
.order_by("updated_at")[: max(1, batch_size)]
|
||||
.values_list("id", flat=True)
|
||||
)
|
||||
for row_id in rows:
|
||||
updated = ExternalSyncEvent.objects.filter(
|
||||
id=row_id,
|
||||
provider=provider,
|
||||
status__in=["pending", "retrying"],
|
||||
).update(status="retrying")
|
||||
if updated:
|
||||
ids.append(str(row_id))
|
||||
return ids
|
||||
|
||||
def _run_event(self, event: ExternalSyncEvent) -> None:
|
||||
provider = get_provider(event.provider)
|
||||
if not bool(getattr(provider, "run_in_worker", False)):
|
||||
return
|
||||
|
||||
cfg = (
|
||||
TaskProviderConfig.objects.filter(
|
||||
user=event.user,
|
||||
provider=event.provider,
|
||||
enabled=True,
|
||||
)
|
||||
.order_by("-updated_at")
|
||||
.first()
|
||||
)
|
||||
if cfg is None:
|
||||
event.status = "failed"
|
||||
event.error = "provider_disabled_or_missing"
|
||||
event.save(update_fields=["status", "error", "updated_at"])
|
||||
return
|
||||
|
||||
payload = dict(event.payload or {})
|
||||
action = str(payload.get("action") or "append_update").strip().lower()
|
||||
provider_payload = dict(payload.get("provider_payload") or payload)
|
||||
|
||||
if action == "create":
|
||||
result = provider.create_task(dict(cfg.settings or {}), provider_payload)
|
||||
elif action == "complete":
|
||||
result = provider.mark_complete(dict(cfg.settings or {}), provider_payload)
|
||||
elif action == "link_task":
|
||||
result = provider.link_task(dict(cfg.settings or {}), provider_payload)
|
||||
else:
|
||||
result = provider.append_update(dict(cfg.settings or {}), provider_payload)
|
||||
|
||||
event.status = "ok" if result.ok else "failed"
|
||||
event.error = str(result.error or "")
|
||||
event.payload = dict(
|
||||
payload,
|
||||
worker_processed=True,
|
||||
result=dict(result.payload or {}),
|
||||
)
|
||||
event.save(update_fields=["status", "error", "payload", "updated_at"])
|
||||
|
||||
if result.ok and result.external_key and event.task_id and not str(event.task.external_key or "").strip():
|
||||
event.task.external_key = str(result.external_key)
|
||||
event.task.save(update_fields=["external_key"])
|
||||
|
||||
def handle(self, *args, **options):
|
||||
once = bool(options.get("once"))
|
||||
sleep_seconds = max(0.2, float(options.get("sleep_seconds") or 2.0))
|
||||
batch_size = max(1, int(options.get("batch_size") or 20))
|
||||
provider_name = str(options.get("provider") or "codex_cli").strip().lower()
|
||||
|
||||
log.info(
|
||||
"codex_worker started provider=%s once=%s sleep=%s batch_size=%s",
|
||||
provider_name,
|
||||
once,
|
||||
sleep_seconds,
|
||||
batch_size,
|
||||
)
|
||||
|
||||
while True:
|
||||
claimed_ids = self._claim_batch(provider_name, batch_size)
|
||||
if not claimed_ids:
|
||||
if once:
|
||||
log.info("codex_worker exiting: no pending events")
|
||||
return
|
||||
time.sleep(sleep_seconds)
|
||||
continue
|
||||
|
||||
for row_id in claimed_ids:
|
||||
event = ExternalSyncEvent.objects.filter(id=row_id).select_related("task", "user").first()
|
||||
if event is None:
|
||||
continue
|
||||
try:
|
||||
self._run_event(event)
|
||||
except Exception as exc:
|
||||
log.exception("codex_worker failed processing id=%s", row_id)
|
||||
ExternalSyncEvent.objects.filter(id=row_id).update(
|
||||
status="failed",
|
||||
error=f"worker_exception:{exc}",
|
||||
)
|
||||
|
||||
if once:
|
||||
log.info("codex_worker processed %s event(s)", len(claimed_ids))
|
||||
return
|
||||
243
core/management/commands/recalculate_contact_availability.py
Normal file
243
core/management/commands/recalculate_contact_availability.py
Normal file
@@ -0,0 +1,243 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
|
||||
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}"
|
||||
)
|
||||
)
|
||||
@@ -1,5 +1,6 @@
|
||||
from asgiref.sync import sync_to_async
|
||||
from django.conf import settings
|
||||
import uuid
|
||||
|
||||
from core.messaging.utils import messages_to_string
|
||||
from core.models import ChatSession, Message, QueuedMessage
|
||||
@@ -316,9 +317,21 @@ async def apply_reaction(
|
||||
target = None
|
||||
target_uuid = str(target_message_id or "").strip()
|
||||
if target_uuid:
|
||||
is_uuid = True
|
||||
try:
|
||||
uuid.UUID(str(target_uuid))
|
||||
except Exception:
|
||||
is_uuid = False
|
||||
if is_uuid:
|
||||
target = await sync_to_async(
|
||||
lambda: queryset.filter(id=target_uuid).order_by("-ts").first()
|
||||
)()
|
||||
if target is None:
|
||||
target = await sync_to_async(
|
||||
lambda: queryset.filter(source_message_id=target_uuid)
|
||||
.order_by("-ts")
|
||||
.first()
|
||||
)()
|
||||
|
||||
if target is None:
|
||||
try:
|
||||
|
||||
236
core/migrations/0033_contactavailability_and_externalchatlink.py
Normal file
236
core/migrations/0033_contactavailability_and_externalchatlink.py
Normal file
@@ -0,0 +1,236 @@
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("core", "0032_commandvariantpolicy_store_document"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="ContactAvailabilitySettings",
|
||||
fields=[
|
||||
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||
("enabled", models.BooleanField(default=True)),
|
||||
("show_in_chat", models.BooleanField(default=True)),
|
||||
("show_in_groups", models.BooleanField(default=True)),
|
||||
("inference_enabled", models.BooleanField(default=True)),
|
||||
("retention_days", models.PositiveIntegerField(default=90)),
|
||||
("fade_threshold_seconds", models.PositiveIntegerField(default=900)),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
(
|
||||
"user",
|
||||
models.OneToOneField(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="contact_availability_settings",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="ContactAvailabilityEvent",
|
||||
fields=[
|
||||
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||
(
|
||||
"service",
|
||||
models.CharField(
|
||||
choices=[("signal", "Signal"), ("whatsapp", "WhatsApp"), ("xmpp", "XMPP"), ("instagram", "Instagram"), ("web", "Web")],
|
||||
max_length=255,
|
||||
),
|
||||
),
|
||||
(
|
||||
"source_kind",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("native_presence", "Native Presence"),
|
||||
("read_receipt", "Read Receipt"),
|
||||
("typing_start", "Typing Start"),
|
||||
("typing_stop", "Typing Stop"),
|
||||
("message_in", "Message In"),
|
||||
("message_out", "Message Out"),
|
||||
("inferred_timeout", "Inferred Timeout"),
|
||||
],
|
||||
max_length=32,
|
||||
),
|
||||
),
|
||||
(
|
||||
"availability_state",
|
||||
models.CharField(
|
||||
choices=[("available", "Available"), ("unavailable", "Unavailable"), ("unknown", "Unknown"), ("fading", "Fading")],
|
||||
max_length=32,
|
||||
),
|
||||
),
|
||||
("confidence", models.FloatField(default=0.0)),
|
||||
("ts", models.BigIntegerField(db_index=True)),
|
||||
("payload", models.JSONField(blank=True, default=dict)),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
(
|
||||
"person",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="availability_events",
|
||||
to="core.person",
|
||||
),
|
||||
),
|
||||
(
|
||||
"person_identifier",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="availability_events",
|
||||
to="core.personidentifier",
|
||||
),
|
||||
),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="contact_availability_events",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"indexes": [
|
||||
models.Index(fields=["user", "person", "ts"], name="core_contac_user_id_0da9b2_idx"),
|
||||
models.Index(fields=["user", "service", "ts"], name="core_contac_user_id_bce271_idx"),
|
||||
models.Index(fields=["user", "availability_state", "ts"], name="core_contac_user_id_1b50b3_idx"),
|
||||
],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="ExternalChatLink",
|
||||
fields=[
|
||||
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||
("provider", models.CharField(default="codex_cli", max_length=64)),
|
||||
("external_chat_id", models.CharField(max_length=255)),
|
||||
("metadata", models.JSONField(blank=True, default=dict)),
|
||||
("enabled", models.BooleanField(default=True)),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
(
|
||||
"person",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="external_chat_links",
|
||||
to="core.person",
|
||||
),
|
||||
),
|
||||
(
|
||||
"person_identifier",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="external_chat_links",
|
||||
to="core.personidentifier",
|
||||
),
|
||||
),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="external_chat_links",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"indexes": [
|
||||
models.Index(fields=["user", "provider", "external_chat_id"], name="core_extern_user_id_f4a7b0_idx"),
|
||||
models.Index(fields=["user", "provider", "enabled"], name="core_extern_user_id_7d2295_idx"),
|
||||
],
|
||||
"constraints": [
|
||||
models.UniqueConstraint(fields=("user", "provider", "external_chat_id"), name="unique_external_chat_link_per_provider"),
|
||||
],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="ContactAvailabilitySpan",
|
||||
fields=[
|
||||
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||
(
|
||||
"service",
|
||||
models.CharField(
|
||||
choices=[("signal", "Signal"), ("whatsapp", "WhatsApp"), ("xmpp", "XMPP"), ("instagram", "Instagram"), ("web", "Web")],
|
||||
max_length=255,
|
||||
),
|
||||
),
|
||||
(
|
||||
"state",
|
||||
models.CharField(
|
||||
choices=[("available", "Available"), ("unavailable", "Unavailable"), ("unknown", "Unknown"), ("fading", "Fading")],
|
||||
max_length=32,
|
||||
),
|
||||
),
|
||||
("start_ts", models.BigIntegerField(db_index=True)),
|
||||
("end_ts", models.BigIntegerField(db_index=True)),
|
||||
("confidence_start", models.FloatField(default=0.0)),
|
||||
("confidence_end", models.FloatField(default=0.0)),
|
||||
("payload", models.JSONField(blank=True, default=dict)),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
(
|
||||
"closing_event",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="closing_spans",
|
||||
to="core.contactavailabilityevent",
|
||||
),
|
||||
),
|
||||
(
|
||||
"opening_event",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="opening_spans",
|
||||
to="core.contactavailabilityevent",
|
||||
),
|
||||
),
|
||||
(
|
||||
"person",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="availability_spans",
|
||||
to="core.person",
|
||||
),
|
||||
),
|
||||
(
|
||||
"person_identifier",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="availability_spans",
|
||||
to="core.personidentifier",
|
||||
),
|
||||
),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="contact_availability_spans",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"indexes": [
|
||||
models.Index(fields=["user", "person", "start_ts"], name="core_contac_user_id_9cd15a_idx"),
|
||||
models.Index(fields=["user", "person", "end_ts"], name="core_contac_user_id_88584a_idx"),
|
||||
models.Index(fields=["user", "service", "start_ts"], name="core_contac_user_id_182ffb_idx"),
|
||||
],
|
||||
},
|
||||
),
|
||||
]
|
||||
162
core/models.py
162
core/models.py
@@ -20,14 +20,14 @@ SERVICE_CHOICES = (
|
||||
)
|
||||
CHANNEL_SERVICE_CHOICES = SERVICE_CHOICES + (("web", "Web"),)
|
||||
MBTI_CHOICES = (
|
||||
("INTJ", "INTJ - Architect"),
|
||||
("INTJ", "INTJ - Architect"),# ;)
|
||||
("INTP", "INTP - Logician"),
|
||||
("ENTJ", "ENTJ - Commander"),
|
||||
("ENTP", "ENTP - Debater"),
|
||||
("INFJ", "INFJ - Advocate"),
|
||||
("INFP", "INFP - Mediator"),
|
||||
("ENFJ", "ENFJ - Protagonist"),
|
||||
("ENFP", "ENFP - Campaigner"),
|
||||
("ENFP", "ENFP - Campaigner"), # <3
|
||||
("ISTJ", "ISTJ - Logistician"),
|
||||
("ISFJ", "ISFJ - Defender"),
|
||||
("ESTJ", "ESTJ - Executive"),
|
||||
@@ -2227,6 +2227,164 @@ class TaskProviderConfig(models.Model):
|
||||
]
|
||||
|
||||
|
||||
class ContactAvailabilitySettings(models.Model):
|
||||
user = models.OneToOneField(
|
||||
User,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="contact_availability_settings",
|
||||
)
|
||||
enabled = models.BooleanField(default=True)
|
||||
show_in_chat = models.BooleanField(default=True)
|
||||
show_in_groups = models.BooleanField(default=True)
|
||||
inference_enabled = models.BooleanField(default=True)
|
||||
retention_days = models.PositiveIntegerField(default=90)
|
||||
fade_threshold_seconds = models.PositiveIntegerField(default=900)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
|
||||
class ContactAvailabilityEvent(models.Model):
|
||||
SOURCE_KIND_CHOICES = (
|
||||
("native_presence", "Native Presence"),
|
||||
("read_receipt", "Read Receipt"),
|
||||
("typing_start", "Typing Start"),
|
||||
("typing_stop", "Typing Stop"),
|
||||
("message_in", "Message In"),
|
||||
("message_out", "Message Out"),
|
||||
("inferred_timeout", "Inferred Timeout"),
|
||||
)
|
||||
STATE_CHOICES = (
|
||||
("available", "Available"),
|
||||
("unavailable", "Unavailable"),
|
||||
("unknown", "Unknown"),
|
||||
("fading", "Fading"),
|
||||
)
|
||||
|
||||
user = models.ForeignKey(
|
||||
User,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="contact_availability_events",
|
||||
)
|
||||
person = models.ForeignKey(
|
||||
Person,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="availability_events",
|
||||
)
|
||||
person_identifier = models.ForeignKey(
|
||||
PersonIdentifier,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="availability_events",
|
||||
)
|
||||
service = models.CharField(max_length=255, choices=CHANNEL_SERVICE_CHOICES)
|
||||
source_kind = models.CharField(max_length=32, choices=SOURCE_KIND_CHOICES)
|
||||
availability_state = models.CharField(max_length=32, choices=STATE_CHOICES)
|
||||
confidence = models.FloatField(default=0.0)
|
||||
ts = models.BigIntegerField(db_index=True)
|
||||
payload = models.JSONField(default=dict, blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
indexes = [
|
||||
models.Index(fields=["user", "person", "ts"]),
|
||||
models.Index(fields=["user", "service", "ts"]),
|
||||
models.Index(fields=["user", "availability_state", "ts"]),
|
||||
]
|
||||
|
||||
|
||||
class ContactAvailabilitySpan(models.Model):
|
||||
STATE_CHOICES = ContactAvailabilityEvent.STATE_CHOICES
|
||||
|
||||
user = models.ForeignKey(
|
||||
User,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="contact_availability_spans",
|
||||
)
|
||||
person = models.ForeignKey(
|
||||
Person,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="availability_spans",
|
||||
)
|
||||
person_identifier = models.ForeignKey(
|
||||
PersonIdentifier,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="availability_spans",
|
||||
)
|
||||
service = models.CharField(max_length=255, choices=CHANNEL_SERVICE_CHOICES)
|
||||
state = models.CharField(max_length=32, choices=STATE_CHOICES)
|
||||
start_ts = models.BigIntegerField(db_index=True)
|
||||
end_ts = models.BigIntegerField(db_index=True)
|
||||
confidence_start = models.FloatField(default=0.0)
|
||||
confidence_end = models.FloatField(default=0.0)
|
||||
opening_event = models.ForeignKey(
|
||||
ContactAvailabilityEvent,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="opening_spans",
|
||||
)
|
||||
closing_event = models.ForeignKey(
|
||||
ContactAvailabilityEvent,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="closing_spans",
|
||||
)
|
||||
payload = models.JSONField(default=dict, blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
indexes = [
|
||||
models.Index(fields=["user", "person", "start_ts"]),
|
||||
models.Index(fields=["user", "person", "end_ts"]),
|
||||
models.Index(fields=["user", "service", "start_ts"]),
|
||||
]
|
||||
|
||||
|
||||
class ExternalChatLink(models.Model):
|
||||
user = models.ForeignKey(
|
||||
User,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="external_chat_links",
|
||||
)
|
||||
provider = models.CharField(max_length=64, default="codex_cli")
|
||||
person = models.ForeignKey(
|
||||
Person,
|
||||
on_delete=models.CASCADE,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="external_chat_links",
|
||||
)
|
||||
person_identifier = models.ForeignKey(
|
||||
PersonIdentifier,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="external_chat_links",
|
||||
)
|
||||
external_chat_id = models.CharField(max_length=255)
|
||||
metadata = models.JSONField(default=dict, blank=True)
|
||||
enabled = models.BooleanField(default=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
indexes = [
|
||||
models.Index(fields=["user", "provider", "external_chat_id"]),
|
||||
models.Index(fields=["user", "provider", "enabled"]),
|
||||
]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["user", "provider", "external_chat_id"],
|
||||
name="unique_external_chat_link_per_provider",
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
class TaskCompletionPattern(models.Model):
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="task_completion_patterns")
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import asyncio
|
||||
import re
|
||||
|
||||
from asgiref.sync import sync_to_async
|
||||
from django.conf import settings
|
||||
@@ -13,6 +14,7 @@ from core.commands.base import CommandContext
|
||||
from core.commands.engine import process_inbound_message
|
||||
from core.messaging import history
|
||||
from core.models import PersonIdentifier
|
||||
from core.presence import AvailabilitySignal, record_native_signal
|
||||
from core.realtime.typing_state import set_person_typing_state
|
||||
from core.translation.engine import process_inbound_translation
|
||||
from core.util import logs
|
||||
@@ -100,6 +102,32 @@ class UnifiedRouter(object):
|
||||
message_text = str(kwargs.get("text") or "").strip()
|
||||
if local_message is None:
|
||||
return
|
||||
identifiers = await self._resolve_identifier_objects(protocol, identifier)
|
||||
if identifiers:
|
||||
outgoing = str(getattr(local_message, "custom_author", "") or "").strip().upper() in {
|
||||
"USER",
|
||||
"BOT",
|
||||
}
|
||||
source_kind = "message_out" if outgoing else "message_in"
|
||||
confidence = 0.65 if outgoing else 0.75
|
||||
for row in identifiers:
|
||||
record_native_signal(
|
||||
AvailabilitySignal(
|
||||
user=row.user,
|
||||
person=row.person,
|
||||
person_identifier=row,
|
||||
service=str(protocol or "").strip().lower(),
|
||||
source_kind=source_kind,
|
||||
availability_state="available",
|
||||
confidence=confidence,
|
||||
ts=int(getattr(local_message, "ts", 0) or 0),
|
||||
payload={
|
||||
"origin": "router.message_received",
|
||||
"message_id": str(getattr(local_message, "id", "") or ""),
|
||||
"outgoing": outgoing,
|
||||
},
|
||||
)
|
||||
)
|
||||
channel_identifier = ""
|
||||
if isinstance(identifier, PersonIdentifier):
|
||||
channel_identifier = str(identifier.identifier or "").strip()
|
||||
@@ -127,6 +155,31 @@ class UnifiedRouter(object):
|
||||
await process_inbound_assist(local_message)
|
||||
except Exception as exc:
|
||||
self.log.warning("Assist/task processing failed: %s", exc)
|
||||
await self._refresh_workspace_metrics_for_identifiers(identifiers)
|
||||
|
||||
async def _refresh_workspace_metrics_for_identifiers(self, identifiers):
|
||||
if not identifiers:
|
||||
return
|
||||
seen = set()
|
||||
for row in identifiers:
|
||||
person = getattr(row, "person", None)
|
||||
user = getattr(row, "user", None)
|
||||
if person is None or user is None:
|
||||
continue
|
||||
person_key = str(getattr(person, "id", "") or "")
|
||||
if not person_key or person_key in seen:
|
||||
continue
|
||||
seen.add(person_key)
|
||||
try:
|
||||
from core.views.workspace import _conversation_for_person
|
||||
|
||||
await sync_to_async(_conversation_for_person)(user, person)
|
||||
except Exception as exc:
|
||||
self.log.warning(
|
||||
"Workspace metrics refresh failed for person=%s: %s",
|
||||
person_key,
|
||||
exc,
|
||||
)
|
||||
|
||||
async def _resolve_identifier_objects(self, protocol, identifier):
|
||||
if isinstance(identifier, PersonIdentifier):
|
||||
@@ -134,9 +187,28 @@ class UnifiedRouter(object):
|
||||
value = str(identifier or "").strip()
|
||||
if not value:
|
||||
return []
|
||||
variants = [value]
|
||||
bare = value.split("@", 1)[0].strip()
|
||||
if bare and bare not in variants:
|
||||
variants.append(bare)
|
||||
if protocol == "signal":
|
||||
digits = re.sub(r"[^0-9]", "", value)
|
||||
if digits and digits not in variants:
|
||||
variants.append(digits)
|
||||
if digits:
|
||||
plus = f"+{digits}"
|
||||
if plus not in variants:
|
||||
variants.append(plus)
|
||||
elif protocol == "whatsapp" and bare:
|
||||
direct = f"{bare}@s.whatsapp.net"
|
||||
group = f"{bare}@g.us"
|
||||
if direct not in variants:
|
||||
variants.append(direct)
|
||||
if group not in variants:
|
||||
variants.append(group)
|
||||
return await sync_to_async(list)(
|
||||
PersonIdentifier.objects.filter(
|
||||
identifier=value,
|
||||
identifier__in=variants,
|
||||
service=protocol,
|
||||
)
|
||||
)
|
||||
@@ -160,12 +232,75 @@ class UnifiedRouter(object):
|
||||
read_by_identifier=read_by or row.identifier,
|
||||
payload=payload,
|
||||
)
|
||||
record_native_signal(
|
||||
AvailabilitySignal(
|
||||
user=row.user,
|
||||
person=row.person,
|
||||
person_identifier=row,
|
||||
service=str(protocol or "").strip().lower(),
|
||||
source_kind="read_receipt",
|
||||
availability_state="available",
|
||||
confidence=0.95,
|
||||
ts=int(read_ts or 0),
|
||||
payload={
|
||||
"origin": "router.message_read",
|
||||
"message_timestamps": [int(v) for v in list(timestamps or []) if str(v).isdigit()],
|
||||
"read_by": str(read_by or row.identifier),
|
||||
},
|
||||
)
|
||||
)
|
||||
await self._refresh_workspace_metrics_for_identifiers(identifiers)
|
||||
|
||||
async def presence_changed(self, protocol, *args, **kwargs):
|
||||
identifier = kwargs.get("identifier")
|
||||
state = str(kwargs.get("state") or "unknown").strip().lower()
|
||||
if state not in {"available", "unavailable", "unknown", "fading"}:
|
||||
state = "unknown"
|
||||
try:
|
||||
confidence = float(kwargs.get("confidence") or 0.6)
|
||||
except Exception:
|
||||
confidence = 0.6
|
||||
try:
|
||||
ts = int(kwargs.get("ts") or 0)
|
||||
except Exception:
|
||||
ts = 0
|
||||
payload = dict(kwargs.get("payload") or {})
|
||||
|
||||
identifiers = await self._resolve_identifier_objects(protocol, identifier)
|
||||
for row in identifiers:
|
||||
record_native_signal(
|
||||
AvailabilitySignal(
|
||||
user=row.user,
|
||||
person=row.person,
|
||||
person_identifier=row,
|
||||
service=str(protocol or "").strip().lower(),
|
||||
source_kind="native_presence",
|
||||
availability_state=state,
|
||||
confidence=confidence,
|
||||
ts=ts,
|
||||
payload=payload,
|
||||
)
|
||||
)
|
||||
await self._refresh_workspace_metrics_for_identifiers(identifiers)
|
||||
|
||||
async def started_typing(self, protocol, *args, **kwargs):
|
||||
self.log.info(f"Started typing ({protocol}) {args} {kwargs}")
|
||||
identifier = kwargs.get("identifier")
|
||||
identifiers = await self._resolve_identifier_objects(protocol, identifier)
|
||||
for src in identifiers:
|
||||
record_native_signal(
|
||||
AvailabilitySignal(
|
||||
user=src.user,
|
||||
person=src.person,
|
||||
person_identifier=src,
|
||||
service=str(protocol or "").strip().lower(),
|
||||
source_kind="typing_start",
|
||||
availability_state="available",
|
||||
confidence=0.9,
|
||||
ts=0,
|
||||
payload={"origin": "router.started_typing"},
|
||||
)
|
||||
)
|
||||
if protocol != "xmpp":
|
||||
set_person_typing_state(
|
||||
user_id=src.user_id,
|
||||
@@ -201,6 +336,19 @@ class UnifiedRouter(object):
|
||||
identifier = kwargs.get("identifier")
|
||||
identifiers = await self._resolve_identifier_objects(protocol, identifier)
|
||||
for src in identifiers:
|
||||
record_native_signal(
|
||||
AvailabilitySignal(
|
||||
user=src.user,
|
||||
person=src.person,
|
||||
person_identifier=src,
|
||||
service=str(protocol or "").strip().lower(),
|
||||
source_kind="typing_stop",
|
||||
availability_state="fading",
|
||||
confidence=0.5,
|
||||
ts=0,
|
||||
payload={"origin": "router.stopped_typing"},
|
||||
)
|
||||
)
|
||||
if protocol != "xmpp":
|
||||
set_person_typing_state(
|
||||
user_id=src.user_id,
|
||||
|
||||
18
core/presence/__init__.py
Normal file
18
core/presence/__init__.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from .engine import (
|
||||
AvailabilitySignal,
|
||||
ensure_fading_state,
|
||||
get_settings,
|
||||
record_inferred_signal,
|
||||
record_native_signal,
|
||||
)
|
||||
from .query import latest_state_for_people, spans_for_range
|
||||
|
||||
__all__ = [
|
||||
"AvailabilitySignal",
|
||||
"ensure_fading_state",
|
||||
"get_settings",
|
||||
"record_inferred_signal",
|
||||
"record_native_signal",
|
||||
"latest_state_for_people",
|
||||
"spans_for_range",
|
||||
]
|
||||
175
core/presence/engine.py
Normal file
175
core/presence/engine.py
Normal file
@@ -0,0 +1,175 @@
|
||||
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,
|
||||
)
|
||||
)
|
||||
28
core/presence/inference.py
Normal file
28
core/presence/inference.py
Normal file
@@ -0,0 +1,28 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
|
||||
def now_ms() -> int:
|
||||
return int(time.time() * 1000)
|
||||
|
||||
|
||||
def fade_confidence(elapsed_ms: int, fade_threshold_seconds: int) -> float:
|
||||
"""
|
||||
Convert elapsed inactivity to a confidence score for fading state.
|
||||
|
||||
Confidence starts at 0.7 when fading begins and decays toward 0.2 over
|
||||
4x fade threshold windows.
|
||||
"""
|
||||
threshold_ms = max(1, int(fade_threshold_seconds or 900) * 1000)
|
||||
if elapsed_ms <= threshold_ms:
|
||||
return 0.7
|
||||
over = min(4.0, float(elapsed_ms - threshold_ms) / float(threshold_ms))
|
||||
return max(0.2, 0.7 - (over * 0.125))
|
||||
|
||||
|
||||
def should_fade(last_event_ts: int, now_ts: int, fade_threshold_seconds: int) -> bool:
|
||||
if last_event_ts <= 0:
|
||||
return False
|
||||
threshold_ms = max(1, int(fade_threshold_seconds or 900) * 1000)
|
||||
return int(now_ts) - int(last_event_ts) >= threshold_ms
|
||||
60
core/presence/query.py
Normal file
60
core/presence/query.py
Normal file
@@ -0,0 +1,60 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from django.db.models import Q
|
||||
|
||||
from core.models import ContactAvailabilityEvent, ContactAvailabilitySpan, Person, User
|
||||
from .engine import ensure_fading_state
|
||||
from .inference import now_ms
|
||||
|
||||
|
||||
def spans_for_range(
|
||||
*,
|
||||
user: User,
|
||||
person: Person,
|
||||
start_ts: int,
|
||||
end_ts: int,
|
||||
service: str = "",
|
||||
limit: int = 200,
|
||||
):
|
||||
qs = ContactAvailabilitySpan.objects.filter(
|
||||
user=user,
|
||||
person=person,
|
||||
).filter(
|
||||
Q(start_ts__lte=end_ts) & Q(end_ts__gte=start_ts)
|
||||
)
|
||||
if service:
|
||||
qs = qs.filter(service=str(service).strip().lower())
|
||||
|
||||
ensure_fading_state(
|
||||
user=user,
|
||||
person=person,
|
||||
person_identifier=None,
|
||||
service=(str(service or "").strip().lower() or "signal"),
|
||||
at_ts=now_ms(),
|
||||
)
|
||||
|
||||
return list(qs.order_by("-end_ts")[: max(1, min(int(limit or 200), 500))])
|
||||
|
||||
|
||||
def latest_state_for_people(*, user: User, person_ids: list, service: str = "") -> dict:
|
||||
out = {}
|
||||
if not person_ids:
|
||||
return out
|
||||
qs = ContactAvailabilityEvent.objects.filter(user=user, person_id__in=person_ids)
|
||||
if service:
|
||||
qs = qs.filter(service=str(service).strip().lower())
|
||||
rows = list(qs.order_by("person_id", "-ts", "-id"))
|
||||
seen = set()
|
||||
for row in rows:
|
||||
person_key = str(row.person_id)
|
||||
if person_key in seen:
|
||||
continue
|
||||
seen.add(person_key)
|
||||
out[person_key] = {
|
||||
"state": str(row.availability_state or "unknown"),
|
||||
"confidence": float(row.confidence or 0.0),
|
||||
"service": str(row.service or ""),
|
||||
"ts": int(row.ts or 0),
|
||||
"source_kind": str(row.source_kind or ""),
|
||||
}
|
||||
return out
|
||||
@@ -4,6 +4,7 @@ import re
|
||||
|
||||
from asgiref.sync import sync_to_async
|
||||
from django.conf import settings
|
||||
from django.db.models import Q
|
||||
|
||||
from core.clients.transport import send_message_raw
|
||||
from core.messaging import ai as ai_runner
|
||||
@@ -14,11 +15,13 @@ from core.models import (
|
||||
DerivedTask,
|
||||
DerivedTaskEvent,
|
||||
ExternalSyncEvent,
|
||||
ExternalChatLink,
|
||||
Message,
|
||||
PersonIdentifier,
|
||||
TaskCompletionPattern,
|
||||
TaskProviderConfig,
|
||||
)
|
||||
from core.tasks.providers.mock import get_provider
|
||||
from core.tasks.providers import get_provider
|
||||
|
||||
_TASK_HINT_RE = re.compile(r"\b(todo|task|action|need to|please)\b", re.IGNORECASE)
|
||||
_COMPLETION_RE = re.compile(r"\b(done|completed|fixed)\s*#([A-Za-z0-9_-]+)\b", re.IGNORECASE)
|
||||
@@ -218,6 +221,64 @@ async def _emit_sync_event(task: DerivedTask, event: DerivedTaskEvent, action: s
|
||||
provider_settings = dict(getattr(cfg, "settings", {}) or {})
|
||||
provider = get_provider(provider_name)
|
||||
idempotency_key = f"{provider_name}:{task.id}:{event.id}"
|
||||
variants = _channel_variants(task.source_service or "", task.source_channel or "")
|
||||
person_identifier = None
|
||||
if variants:
|
||||
person_identifier = await sync_to_async(
|
||||
lambda: PersonIdentifier.objects.filter(
|
||||
user=task.user,
|
||||
service=task.source_service,
|
||||
identifier__in=variants,
|
||||
)
|
||||
.select_related("person")
|
||||
.order_by("-id")
|
||||
.first()
|
||||
)()
|
||||
external_chat_id = ""
|
||||
if person_identifier is not None:
|
||||
link = await sync_to_async(
|
||||
lambda: ExternalChatLink.objects.filter(
|
||||
user=task.user,
|
||||
provider=provider_name,
|
||||
enabled=True,
|
||||
)
|
||||
.filter(
|
||||
Q(person_identifier=person_identifier)
|
||||
| Q(person=person_identifier.person)
|
||||
)
|
||||
.order_by("-updated_at", "-id")
|
||||
.first()
|
||||
)()
|
||||
if link is not None:
|
||||
external_chat_id = str(link.external_chat_id or "").strip()
|
||||
|
||||
# Worker-backed providers are queued and executed by `manage.py codex_worker`.
|
||||
if bool(getattr(provider, "run_in_worker", False)):
|
||||
await sync_to_async(ExternalSyncEvent.objects.update_or_create)(
|
||||
idempotency_key=idempotency_key,
|
||||
defaults={
|
||||
"user": task.user,
|
||||
"task": task,
|
||||
"task_event": event,
|
||||
"provider": provider_name,
|
||||
"status": "pending",
|
||||
"payload": {
|
||||
"action": action,
|
||||
"provider_payload": {
|
||||
"task_id": str(task.id),
|
||||
"title": task.title,
|
||||
"external_key": task.external_key,
|
||||
"reference_code": task.reference_code,
|
||||
"source_service": str(task.source_service or ""),
|
||||
"source_channel": str(task.source_channel or ""),
|
||||
"external_chat_id": external_chat_id,
|
||||
"payload": event.payload,
|
||||
},
|
||||
},
|
||||
"error": "",
|
||||
},
|
||||
)
|
||||
return
|
||||
|
||||
if action == "create":
|
||||
result = provider.create_task(provider_settings, {
|
||||
@@ -225,18 +286,27 @@ async def _emit_sync_event(task: DerivedTask, event: DerivedTaskEvent, action: s
|
||||
"title": task.title,
|
||||
"external_key": task.external_key,
|
||||
"reference_code": task.reference_code,
|
||||
"source_service": str(task.source_service or ""),
|
||||
"source_channel": str(task.source_channel or ""),
|
||||
"external_chat_id": external_chat_id,
|
||||
})
|
||||
elif action == "complete":
|
||||
result = provider.mark_complete(provider_settings, {
|
||||
"task_id": str(task.id),
|
||||
"external_key": task.external_key,
|
||||
"reference_code": task.reference_code,
|
||||
"source_service": str(task.source_service or ""),
|
||||
"source_channel": str(task.source_channel or ""),
|
||||
"external_chat_id": external_chat_id,
|
||||
})
|
||||
else:
|
||||
result = provider.append_update(provider_settings, {
|
||||
"task_id": str(task.id),
|
||||
"external_key": task.external_key,
|
||||
"reference_code": task.reference_code,
|
||||
"source_service": str(task.source_service or ""),
|
||||
"source_channel": str(task.source_channel or ""),
|
||||
"external_chat_id": external_chat_id,
|
||||
"payload": event.payload,
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .base import TaskProvider
|
||||
from .codex_cli import CodexCLITaskProvider
|
||||
from .mock import MockTaskProvider
|
||||
|
||||
PROVIDERS = {
|
||||
"mock": MockTaskProvider(),
|
||||
"codex_cli": CodexCLITaskProvider(),
|
||||
}
|
||||
|
||||
|
||||
def get_provider(name: str) -> TaskProvider:
|
||||
key = str(name or "").strip().lower()
|
||||
return PROVIDERS.get(key, PROVIDERS["mock"])
|
||||
|
||||
|
||||
def list_providers() -> list[TaskProvider]:
|
||||
return list(PROVIDERS.values())
|
||||
|
||||
@@ -13,6 +13,7 @@ class ProviderResult:
|
||||
|
||||
class TaskProvider:
|
||||
name = "base"
|
||||
run_in_worker = False
|
||||
|
||||
def healthcheck(self, config: dict) -> ProviderResult:
|
||||
raise NotImplementedError
|
||||
|
||||
117
core/tasks/providers/codex_cli.py
Normal file
117
core/tasks/providers/codex_cli.py
Normal file
@@ -0,0 +1,117 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
|
||||
from .base import ProviderResult, TaskProvider
|
||||
|
||||
|
||||
class CodexCLITaskProvider(TaskProvider):
|
||||
name = "codex_cli"
|
||||
run_in_worker = True
|
||||
|
||||
def _timeout(self, config: dict) -> int:
|
||||
try:
|
||||
return max(1, int(config.get("timeout_seconds") or 60))
|
||||
except Exception:
|
||||
return 60
|
||||
|
||||
def _command(self, config: dict) -> str:
|
||||
return str(config.get("command") or "codex").strip() or "codex"
|
||||
|
||||
def _workspace(self, config: dict) -> str:
|
||||
return str(config.get("workspace_root") or "").strip()
|
||||
|
||||
def _profile(self, config: dict) -> str:
|
||||
return str(config.get("default_profile") or "").strip()
|
||||
|
||||
def _run(self, config: dict, op: str, payload: dict) -> ProviderResult:
|
||||
cmd = [self._command(config), "task-sync", "--op", str(op)]
|
||||
workspace = self._workspace(config)
|
||||
if workspace:
|
||||
cmd.extend(["--workspace", workspace])
|
||||
profile = self._profile(config)
|
||||
if profile:
|
||||
cmd.extend(["--profile", profile])
|
||||
command_timeout = self._timeout(config)
|
||||
data = json.dumps(dict(payload or {}), separators=(",", ":"))
|
||||
cmd.extend(["--payload-json", data])
|
||||
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=command_timeout,
|
||||
check=False,
|
||||
cwd=workspace if workspace else None,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
return ProviderResult(
|
||||
ok=False,
|
||||
error=f"codex_cli_timeout_{command_timeout}s",
|
||||
payload={"op": op, "timeout_seconds": command_timeout},
|
||||
)
|
||||
except Exception as exc:
|
||||
return ProviderResult(ok=False, error=f"codex_cli_exec_error:{exc}", payload={"op": op})
|
||||
|
||||
stdout = str(completed.stdout or "").strip()
|
||||
stderr = str(completed.stderr or "").strip()
|
||||
parsed = {}
|
||||
if stdout:
|
||||
try:
|
||||
parsed = json.loads(stdout)
|
||||
if not isinstance(parsed, dict):
|
||||
parsed = {"raw_stdout": stdout}
|
||||
except Exception:
|
||||
parsed = {"raw_stdout": stdout}
|
||||
|
||||
ext = (
|
||||
str(parsed.get("external_key") or "").strip()
|
||||
or str(parsed.get("task_id") or "").strip()
|
||||
or str(payload.get("external_key") or "").strip()
|
||||
)
|
||||
|
||||
ok = completed.returncode == 0
|
||||
out_payload = {
|
||||
"op": op,
|
||||
"returncode": int(completed.returncode),
|
||||
"stdout": stdout[:4000],
|
||||
"stderr": stderr[:4000],
|
||||
}
|
||||
out_payload.update(parsed)
|
||||
return ProviderResult(ok=ok, external_key=ext, error=("" if ok else stderr[:4000]), payload=out_payload)
|
||||
|
||||
def healthcheck(self, config: dict) -> ProviderResult:
|
||||
command = self._command(config)
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
[command, "--version"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=max(1, min(20, self._timeout(config))),
|
||||
check=False,
|
||||
)
|
||||
except Exception as exc:
|
||||
return ProviderResult(ok=False, error=f"codex_cli_unavailable:{exc}")
|
||||
return ProviderResult(
|
||||
ok=(completed.returncode == 0),
|
||||
payload={
|
||||
"returncode": int(completed.returncode),
|
||||
"stdout": str(completed.stdout or "").strip()[:1000],
|
||||
"stderr": str(completed.stderr or "").strip()[:1000],
|
||||
},
|
||||
error=("" if completed.returncode == 0 else str(completed.stderr or "").strip()[:1000]),
|
||||
)
|
||||
|
||||
def create_task(self, config: dict, payload: dict) -> ProviderResult:
|
||||
return self._run(config, "create", payload)
|
||||
|
||||
def append_update(self, config: dict, payload: dict) -> ProviderResult:
|
||||
return self._run(config, "append_update", payload)
|
||||
|
||||
def mark_complete(self, config: dict, payload: dict) -> ProviderResult:
|
||||
return self._run(config, "mark_complete", payload)
|
||||
|
||||
def link_task(self, config: dict, payload: dict) -> ProviderResult:
|
||||
return self._run(config, "link_task", payload)
|
||||
@@ -23,12 +23,3 @@ class MockTaskProvider(TaskProvider):
|
||||
|
||||
def link_task(self, config: dict, payload: dict) -> ProviderResult:
|
||||
return ProviderResult(ok=True, external_key=str(payload.get("external_key") or ""), payload={"action": "link_task"})
|
||||
|
||||
|
||||
PROVIDERS = {
|
||||
"mock": MockTaskProvider(),
|
||||
}
|
||||
|
||||
|
||||
def get_provider(name: str) -> TaskProvider:
|
||||
return PROVIDERS.get(str(name or "").strip().lower(), PROVIDERS["mock"])
|
||||
|
||||
@@ -377,6 +377,9 @@
|
||||
<a class="navbar-item" href="{% url 'sessions' type='page' %}">
|
||||
Sessions
|
||||
</a>
|
||||
<a class="navbar-item" href="{% url 'command_routing' %}#bp-documents">
|
||||
Documents
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -401,6 +404,9 @@
|
||||
<a class="navbar-item" href="{% url 'tasks_settings' %}">
|
||||
Task Settings
|
||||
</a>
|
||||
<a class="navbar-item" href="{% url 'availability_settings' %}">
|
||||
Availability
|
||||
</a>
|
||||
<a class="navbar-item" href="{% url 'translation_settings' %}">
|
||||
Translation
|
||||
</a>
|
||||
|
||||
128
core/templates/pages/availability-settings.html
Normal file
128
core/templates/pages/availability-settings.html
Normal file
@@ -0,0 +1,128 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<section class="section">
|
||||
<div class="container">
|
||||
<h1 class="title is-4">Availability Settings</h1>
|
||||
<form method="post" class="box">
|
||||
{% csrf_token %}
|
||||
<div class="columns is-multiline">
|
||||
<div class="column is-3"><label class="checkbox"><input type="checkbox" name="enabled" {% if settings_row.enabled %}checked{% endif %}> Enabled</label></div>
|
||||
<div class="column is-3"><label class="checkbox"><input type="checkbox" name="show_in_chat" {% if settings_row.show_in_chat %}checked{% endif %}> Show In Chat</label></div>
|
||||
<div class="column is-3"><label class="checkbox"><input type="checkbox" name="show_in_groups" {% if settings_row.show_in_groups %}checked{% endif %}> Show In Groups</label></div>
|
||||
<div class="column is-3"><label class="checkbox"><input type="checkbox" name="inference_enabled" {% if settings_row.inference_enabled %}checked{% endif %}> Inference Enabled</label></div>
|
||||
<div class="column is-3">
|
||||
<label class="label is-size-7">Retention Days</label>
|
||||
<input class="input is-small" type="number" min="1" name="retention_days" value="{{ settings_row.retention_days }}">
|
||||
</div>
|
||||
<div class="column is-3">
|
||||
<label class="label is-size-7">Fade Threshold (seconds)</label>
|
||||
<input class="input is-small" type="number" min="30" name="fade_threshold_seconds" value="{{ settings_row.fade_threshold_seconds }}">
|
||||
</div>
|
||||
</div>
|
||||
<button class="button is-link is-small" type="submit">Save</button>
|
||||
</form>
|
||||
|
||||
<form method="get" class="box">
|
||||
<h2 class="title is-6">Timeline Filters</h2>
|
||||
<div class="columns is-multiline">
|
||||
<div class="column is-3">
|
||||
<label class="label is-size-7">Person</label>
|
||||
<div class="select is-small is-fullwidth">
|
||||
<select name="person">
|
||||
<option value="">All</option>
|
||||
{% for person in people %}
|
||||
<option value="{{ person.id }}" {% if filters.person == person.id|stringformat:"s" %}selected{% endif %}>{{ person.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-2">
|
||||
<label class="label is-size-7">Service</label>
|
||||
<div class="select is-small is-fullwidth">
|
||||
<select name="service">
|
||||
<option value="">All</option>
|
||||
{% for item in service_choices %}
|
||||
<option value="{{ item }}" {% if filters.service == item %}selected{% endif %}>{{ item }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-2">
|
||||
<label class="label is-size-7">State</label>
|
||||
<div class="select is-small is-fullwidth">
|
||||
<select name="state">
|
||||
<option value="">All</option>
|
||||
{% for item in state_choices %}
|
||||
<option value="{{ item }}" {% if filters.state == item %}selected{% endif %}>{{ item }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-2">
|
||||
<label class="label is-size-7">Source</label>
|
||||
<div class="select is-small is-fullwidth">
|
||||
<select name="source_kind">
|
||||
<option value="">All</option>
|
||||
{% for item in source_kind_choices %}
|
||||
<option value="{{ item }}" {% if filters.source_kind == item %}selected{% endif %}>{{ item }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-2">
|
||||
<label class="label is-size-7">Start (ISO)</label>
|
||||
<input class="input is-small" type="datetime-local" name="start" value="{{ filters.start }}">
|
||||
</div>
|
||||
<div class="column is-2">
|
||||
<label class="label is-size-7">End (ISO)</label>
|
||||
<input class="input is-small" type="datetime-local" name="end" value="{{ filters.end }}">
|
||||
</div>
|
||||
</div>
|
||||
<button class="button is-light is-small" type="submit">Apply</button>
|
||||
</form>
|
||||
|
||||
<div class="box">
|
||||
<h2 class="title is-6">Availability Events</h2>
|
||||
<table class="table is-fullwidth is-striped is-size-7">
|
||||
<thead><tr><th>ts</th><th>person</th><th>service</th><th>source</th><th>state</th><th>confidence</th></tr></thead>
|
||||
<tbody>
|
||||
{% for row in events %}
|
||||
<tr>
|
||||
<td>{{ row.ts }}</td>
|
||||
<td>{{ row.person.name }}</td>
|
||||
<td>{{ row.service }}</td>
|
||||
<td>{{ row.source_kind }}</td>
|
||||
<td>{{ row.availability_state }}</td>
|
||||
<td>{{ row.confidence|floatformat:2 }}</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="6">No events in range.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="box">
|
||||
<h2 class="title is-6">Availability Spans</h2>
|
||||
<table class="table is-fullwidth is-striped is-size-7">
|
||||
<thead><tr><th>person</th><th>service</th><th>state</th><th>start</th><th>end</th><th>confidence</th></tr></thead>
|
||||
<tbody>
|
||||
{% for row in spans %}
|
||||
<tr>
|
||||
<td>{{ row.person.name }}</td>
|
||||
<td>{{ row.service }}</td>
|
||||
<td>{{ row.state }}</td>
|
||||
<td>{{ row.start_ts }}</td>
|
||||
<td>{{ row.end_ts }}</td>
|
||||
<td>{{ row.confidence_start|floatformat:2 }} -> {{ row.confidence_end|floatformat:2 }}</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="6">No spans in range.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -150,10 +150,10 @@
|
||||
{% if variant.warn_verbatim_plan %}
|
||||
<tr>
|
||||
<td colspan="9">
|
||||
<p class="help has-text-warning-dark">
|
||||
Warning: <strong>{{ variant.variant_label }}</strong> is in <code>verbatim</code> mode with plan fanout enabled.
|
||||
<article class="command-variant-warning">
|
||||
<strong>Warning:</strong> <strong>{{ variant.variant_label }}</strong> is in <code>verbatim</code> mode with plan fanout enabled.
|
||||
Recipients will get raw transcript-style output.
|
||||
</p>
|
||||
</article>
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
@@ -188,12 +188,15 @@
|
||||
|
||||
<h4 class="title is-7" style="margin-top: 0.95rem;">Effective Destinations</h4>
|
||||
{% if profile.enabled_egress_bindings %}
|
||||
<ul class="is-size-7">
|
||||
<ul class="command-destination-list is-size-7">
|
||||
{% for row in profile.enabled_egress_bindings %}
|
||||
<li>{{ row.service }} · <code>{{ row.channel_identifier }}</code></li>
|
||||
<li class="command-destination-item">
|
||||
<span class="tag is-link is-light is-rounded is-small">{{ row.service }}</span>
|
||||
<code>{{ row.channel_identifier }}</code>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<p class="help">{{ profile.enabled_egress_bindings|length }} enabled egress destination{{ profile.enabled_egress_bindings|length|pluralize }}.</p>
|
||||
<p class="command-destination-summary">{{ profile.enabled_egress_bindings|length }} enabled egress destination{{ profile.enabled_egress_bindings|length|pluralize }}.</p>
|
||||
{% else %}
|
||||
<article class="notification is-warning is-light is-size-7">No enabled egress destinations. Plan fanout will show sent:0.</article>
|
||||
{% endif %}
|
||||
@@ -383,7 +386,7 @@
|
||||
<article class="notification is-light">No command profiles configured.</article>
|
||||
{% endfor %}
|
||||
|
||||
<article class="box">
|
||||
<article class="box" id="bp-documents">
|
||||
<h2 class="title is-6">Business Plan Documents</h2>
|
||||
<table class="table is-fullwidth is-striped is-size-7">
|
||||
<thead>
|
||||
@@ -436,5 +439,44 @@
|
||||
.command-order-capsule-form + .command-order-capsule-form .command-order-btn {
|
||||
border-top: 1px solid #dbdbdb;
|
||||
}
|
||||
.command-variant-warning {
|
||||
border: 1px solid rgba(171, 109, 17, 0.45);
|
||||
background: linear-gradient(180deg, rgba(255, 246, 226, 0.98), rgba(255, 238, 204, 0.95));
|
||||
color: #6e450e;
|
||||
border-radius: 8px;
|
||||
padding: 0.48rem 0.62rem;
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.command-destination-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.28rem;
|
||||
}
|
||||
.command-destination-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.42rem;
|
||||
background: rgba(244, 248, 255, 0.85);
|
||||
border: 1px solid rgba(58, 103, 165, 0.2);
|
||||
border-radius: 7px;
|
||||
padding: 0.26rem 0.38rem;
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
}
|
||||
.command-destination-summary {
|
||||
margin-top: 0.44rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border-radius: 999px;
|
||||
background: rgba(239, 247, 255, 0.95);
|
||||
border: 1px solid rgba(58, 103, 165, 0.25);
|
||||
padding: 0.16rem 0.52rem;
|
||||
font-size: 0.73rem;
|
||||
color: #284d7c;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
@@ -316,21 +316,112 @@
|
||||
|
||||
<div class="column is-6">
|
||||
<section class="tasks-panel">
|
||||
<h3 class="title is-7">Provider</h3>
|
||||
<h3 class="title is-7">Providers</h3>
|
||||
<p class="help">Controls outbound sync to external tracking systems. If disabled, tasks are still derived and visible inside GIA only.</p>
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="provider_update">
|
||||
<input type="hidden" name="provider" value="mock">
|
||||
<label class="checkbox"><input type="checkbox" name="enabled" value="1" {% if provider_configs and provider_configs.0.enabled %}checked{% endif %}> Enable mock provider</label>
|
||||
<label class="checkbox"><input type="checkbox" name="enabled" value="1" {% if mock_provider_config and mock_provider_config.enabled %}checked{% endif %}> Enable mock provider</label>
|
||||
<p class="help">Mock provider logs sync events without writing to a real third-party system.</p>
|
||||
<div style="margin-top:0.5rem;">
|
||||
<button class="button is-small is-link is-light" type="submit">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
<hr>
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="provider_update">
|
||||
<input type="hidden" name="provider" value="codex_cli">
|
||||
<label class="checkbox"><input type="checkbox" name="enabled" value="1" {% if codex_provider_config and codex_provider_config.enabled %}checked{% endif %}> Enable Codex CLI provider</label>
|
||||
<p class="help">Codex task-sync runs in a dedicated worker (<code>python manage.py codex_worker</code>).</p>
|
||||
<div class="field" style="margin-top:0.5rem;">
|
||||
<label class="label is-size-7">Command</label>
|
||||
<input class="input is-small" name="command" value="{{ codex_provider_settings.command }}" placeholder="codex">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label is-size-7">Workspace Root</label>
|
||||
<input class="input is-small" name="workspace_root" value="{{ codex_provider_settings.workspace_root }}" placeholder="/code/xf">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label is-size-7">Default Profile</label>
|
||||
<input class="input is-small" name="default_profile" value="{{ codex_provider_settings.default_profile }}" placeholder="default">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label is-size-7">Timeout Seconds</label>
|
||||
<input class="input is-small" type="number" min="1" name="timeout_seconds" value="{{ codex_provider_settings.timeout_seconds }}">
|
||||
</div>
|
||||
<div style="margin-top:0.5rem;">
|
||||
<button class="button is-small is-link is-light" type="submit">Save Codex Provider</button>
|
||||
</div>
|
||||
</form>
|
||||
<p class="help">Browse all derived tasks in <a href="{% url 'tasks_hub' %}">Tasks Hub</a>.</p>
|
||||
</section>
|
||||
</div>
|
||||
<div class="column is-12">
|
||||
<section class="tasks-panel">
|
||||
<h3 class="title is-7">External Chat Links</h3>
|
||||
<p class="help">Map a contact to an external Codex chat/session ID for task-sync metadata.</p>
|
||||
<form method="post" class="block">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="external_chat_link_upsert">
|
||||
<div class="columns tasks-settings-inline-columns">
|
||||
<div class="column is-2">
|
||||
<label class="label is-size-7">Provider</label>
|
||||
<div class="select is-small is-fullwidth">
|
||||
<select name="provider">
|
||||
<option value="codex_cli" selected>codex_cli</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-5">
|
||||
<label class="label is-size-7">Contact Identifier</label>
|
||||
<div class="select is-small is-fullwidth">
|
||||
<select name="person_identifier_id">
|
||||
<option value="">Unlinked</option>
|
||||
{% for row in person_identifiers %}
|
||||
<option value="{{ row.id }}">{{ row.person.name }} · {{ row.service }} · {{ row.identifier }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-3">
|
||||
<label class="label is-size-7">External Chat ID</label>
|
||||
<input class="input is-small" name="external_chat_id" placeholder="codex-chat-...">
|
||||
</div>
|
||||
<div class="column is-2">
|
||||
<label class="label is-size-7">Enabled</label>
|
||||
<label class="checkbox"><input type="checkbox" name="enabled" value="1" checked> Active</label>
|
||||
</div>
|
||||
</div>
|
||||
<button class="button is-small is-link is-light" type="submit">Save Link</button>
|
||||
</form>
|
||||
<table class="table is-fullwidth is-striped is-size-7">
|
||||
<thead><tr><th>Provider</th><th>Person</th><th>Identifier</th><th>External Chat</th><th>Enabled</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{% for row in external_chat_links %}
|
||||
<tr>
|
||||
<td>{{ row.provider }}</td>
|
||||
<td>{% if row.person %}{{ row.person.name }}{% else %}-{% endif %}</td>
|
||||
<td>{% if row.person_identifier %}{{ row.person_identifier.service }} · {{ row.person_identifier.identifier }}{% else %}-{% endif %}</td>
|
||||
<td>{{ row.external_chat_id }}</td>
|
||||
<td>{{ row.enabled }}</td>
|
||||
<td>
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="external_chat_link_delete">
|
||||
<input type="hidden" name="external_link_id" value="{{ row.id }}">
|
||||
<button class="button is-danger is-light is-small" type="submit">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="6">No external chat links.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
@@ -142,6 +142,18 @@
|
||||
<span class="icon is-small"><i class="{{ manual_icon_class }}"></i></span>
|
||||
<span>AI Workspace</span>
|
||||
</a>
|
||||
{% if ai_workspace_graphs_url and ai_workspace_info_url %}
|
||||
<span class="compose-insights-capsule">
|
||||
<a class="compose-insights-link" href="{{ ai_workspace_graphs_url }}">
|
||||
<span class="icon is-small"><i class="fa-solid fa-chart-line"></i></span>
|
||||
<span>Graphs</span>
|
||||
</a>
|
||||
<a class="compose-insights-link" href="{{ ai_workspace_info_url }}">
|
||||
<span class="icon is-small"><i class="fa-solid fa-circle-info"></i></span>
|
||||
<span>Info</span>
|
||||
</a>
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if ai_workspace_widget_url %}
|
||||
<button
|
||||
type="button"
|
||||
@@ -354,6 +366,20 @@
|
||||
aria-label="Export conversation history"></textarea>
|
||||
</div>
|
||||
|
||||
<div
|
||||
id="{{ panel_id }}-availability"
|
||||
class="compose-availability-lane{% if not availability_enabled %} is-hidden{% endif %}"
|
||||
data-slices='{{ availability_slices_json|default:"[]"|escapejs }}'
|
||||
aria-label="Contact availability timeline">
|
||||
{% for row in availability_slices %}
|
||||
<span
|
||||
class="compose-availability-chip is-{{ row.state }}"
|
||||
title="{{ row.state|title }} via {{ row.service|upper }} ({{ row.confidence_end|floatformat:2 }})">
|
||||
{{ row.state|title }} · {{ row.service|upper }}
|
||||
</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div
|
||||
id="{{ panel_id }}-thread"
|
||||
class="compose-thread"
|
||||
@@ -553,6 +579,45 @@
|
||||
padding: 0.65rem;
|
||||
background: linear-gradient(180deg, rgba(248, 250, 252, 0.7), rgba(255, 255, 255, 0.98));
|
||||
}
|
||||
#{{ panel_id }} .compose-availability-lane {
|
||||
margin-top: 0.42rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.24rem;
|
||||
}
|
||||
#{{ panel_id }} .compose-availability-lane.is-hidden {
|
||||
display: none;
|
||||
}
|
||||
#{{ panel_id }} .compose-availability-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border-radius: 999px;
|
||||
padding: 0.06rem 0.45rem;
|
||||
font-size: 0.62rem;
|
||||
border: 1px solid rgba(0, 0, 0, 0.15);
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
color: #35465a;
|
||||
line-height: 1.2;
|
||||
}
|
||||
#{{ panel_id }} .compose-availability-chip.is-available {
|
||||
border-color: rgba(28, 144, 77, 0.4);
|
||||
background: rgba(228, 249, 237, 0.98);
|
||||
color: #1a6f3d;
|
||||
}
|
||||
#{{ panel_id }} .compose-availability-chip.is-fading {
|
||||
border-color: rgba(187, 119, 18, 0.4);
|
||||
background: rgba(255, 247, 230, 0.98);
|
||||
color: #8a5b13;
|
||||
}
|
||||
#{{ panel_id }} .compose-availability-chip.is-unavailable {
|
||||
border-color: rgba(194, 37, 37, 0.35);
|
||||
background: rgba(255, 236, 236, 0.98);
|
||||
color: #8f1e1e;
|
||||
}
|
||||
#{{ panel_id }} .compose-body,
|
||||
#{{ panel_id }} .compose-reaction-chip {
|
||||
font-family: inherit, "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji";
|
||||
}
|
||||
#{{ panel_id }} .compose-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -572,6 +637,32 @@
|
||||
#{{ panel_id }} .compose-row.is-out {
|
||||
align-items: flex-end;
|
||||
}
|
||||
#{{ panel_id }} .compose-insights-capsule {
|
||||
display: inline-flex;
|
||||
border: 1px solid rgba(38, 77, 127, 0.28);
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(180deg, rgba(240, 246, 255, 0.96), rgba(233, 242, 255, 0.9));
|
||||
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.85) inset;
|
||||
}
|
||||
#{{ panel_id }} .compose-insights-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.24rem;
|
||||
padding: 0.32rem 0.62rem;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
color: #1f4f82;
|
||||
text-decoration: none;
|
||||
line-height: 1;
|
||||
}
|
||||
#{{ panel_id }} .compose-insights-link + .compose-insights-link {
|
||||
border-left: 1px solid rgba(38, 77, 127, 0.22);
|
||||
}
|
||||
#{{ panel_id }} .compose-insights-link:hover {
|
||||
background: rgba(219, 234, 255, 0.92);
|
||||
color: #153a63;
|
||||
}
|
||||
#{{ panel_id }} .compose-row.is-group-middle,
|
||||
#{{ panel_id }} .compose-row.is-group-first {
|
||||
margin-bottom: 0.16rem;
|
||||
@@ -1759,6 +1850,7 @@
|
||||
const exportCopy = document.getElementById(panelId + "-export-copy");
|
||||
const exportClear = document.getElementById(panelId + "-export-clear");
|
||||
const exportBuffer = document.getElementById(panelId + "-export-buffer");
|
||||
const availabilityLane = document.getElementById(panelId + "-availability");
|
||||
const csrfToken = "{{ csrf_token }}";
|
||||
if (lightbox && lightbox.parentElement !== document.body) {
|
||||
document.body.appendChild(lightbox);
|
||||
@@ -3025,6 +3117,40 @@
|
||||
updateExportBuffer();
|
||||
};
|
||||
|
||||
const renderAvailabilitySlices = function (slices) {
|
||||
if (!availabilityLane) {
|
||||
return;
|
||||
}
|
||||
const rows = Array.isArray(slices) ? slices : [];
|
||||
availabilityLane.innerHTML = "";
|
||||
if (!rows.length) {
|
||||
availabilityLane.classList.add("is-hidden");
|
||||
return;
|
||||
}
|
||||
rows.forEach(function (item) {
|
||||
const chip = document.createElement("span");
|
||||
const state = String((item && item.state) || "unknown").toLowerCase();
|
||||
const service = String((item && item.service) || "").toUpperCase();
|
||||
const confidence = Number((item && item.confidence_end) || 0);
|
||||
const payload = (item && typeof item.payload === "object" && item.payload) ? item.payload : {};
|
||||
const inferredFrom = String(payload.inferred_from || payload.extended_by || "").trim();
|
||||
const lastSeenTs = Number(payload.last_seen_ts || 0);
|
||||
chip.className = "compose-availability-chip is-" + state;
|
||||
chip.textContent = (state.charAt(0).toUpperCase() + state.slice(1)) + (service ? (" · " + service) : "");
|
||||
const meta = [];
|
||||
meta.push("confidence " + confidence.toFixed(2));
|
||||
if (inferredFrom) {
|
||||
meta.push("source " + inferredFrom);
|
||||
}
|
||||
if (lastSeenTs > 0) {
|
||||
meta.push("last seen " + new Date(lastSeenTs).toLocaleString());
|
||||
}
|
||||
chip.title = chip.textContent + " (" + meta.join(", ") + ")";
|
||||
availabilityLane.appendChild(chip);
|
||||
});
|
||||
availabilityLane.classList.remove("is-hidden");
|
||||
};
|
||||
|
||||
const applyTyping = function (typingPayload) {
|
||||
if (!typingNode || !typingPayload || typeof typingPayload !== "object") {
|
||||
return;
|
||||
@@ -3059,6 +3185,9 @@
|
||||
if (payload.typing) {
|
||||
applyTyping(payload.typing);
|
||||
}
|
||||
if (payload.availability_slices) {
|
||||
renderAvailabilitySlices(payload.availability_slices);
|
||||
}
|
||||
if (payload.last_ts !== undefined && payload.last_ts !== null) {
|
||||
lastTs = Math.max(lastTs, toInt(payload.last_ts));
|
||||
thread.dataset.lastTs = String(lastTs);
|
||||
@@ -3096,6 +3225,9 @@
|
||||
if (payload.typing) {
|
||||
applyTyping(payload.typing);
|
||||
}
|
||||
if (payload.availability_slices) {
|
||||
renderAvailabilitySlices(payload.availability_slices);
|
||||
}
|
||||
if (payload.last_ts !== undefined && payload.last_ts !== null) {
|
||||
lastTs = Math.max(lastTs, toInt(payload.last_ts));
|
||||
thread.dataset.lastTs = String(lastTs);
|
||||
@@ -3138,6 +3270,12 @@
|
||||
try {
|
||||
const initialTyping = JSON.parse("{{ typing_state_json|escapejs }}");
|
||||
applyTyping(initialTyping);
|
||||
try {
|
||||
const initialSlices = JSON.parse(String((availabilityLane && availabilityLane.dataset.slices) || "[]"));
|
||||
renderAvailabilitySlices(initialSlices);
|
||||
} catch (err) {
|
||||
renderAvailabilitySlices([]);
|
||||
}
|
||||
} catch (err) {
|
||||
// Ignore invalid initial typing state payload.
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
<th>account</th>
|
||||
<th>name</th>
|
||||
<th>person</th>
|
||||
<th>availability</th>
|
||||
<th>actions</th>
|
||||
</thead>
|
||||
{% for item in object_list %}
|
||||
@@ -36,6 +37,13 @@
|
||||
{% if item.chat %}{{ item.chat.source_name }}{% else %}{{ item.name }}{% endif %}
|
||||
</td>
|
||||
<td>{{ item.person_name|default:"-" }}</td>
|
||||
<td>
|
||||
{% if item.availability_label %}
|
||||
<span class="tag is-light">{{ item.availability_label }}</span>
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<div class="buttons">
|
||||
{% if not item.is_group %}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
<th>chat</th>
|
||||
<th>identifier</th>
|
||||
<th>person</th>
|
||||
<th>availability</th>
|
||||
<th>actions</th>
|
||||
</thead>
|
||||
{% for item in object_list %}
|
||||
@@ -25,6 +26,13 @@
|
||||
</a>
|
||||
</td>
|
||||
<td>{{ item.person_name|default:"-" }}</td>
|
||||
<td>
|
||||
{% if item.availability_label %}
|
||||
<span class="tag is-light">{{ item.availability_label }}</span>
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<div class="buttons">
|
||||
{% if type == 'page' %}
|
||||
@@ -52,7 +60,7 @@
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="4" class="has-text-grey">No WhatsApp chats discovered yet.</td>
|
||||
<td colspan="5" class="has-text-grey">No WhatsApp chats discovered yet.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
|
||||
38
core/tests/test_availability_settings_page.py
Normal file
38
core/tests/test_availability_settings_page.py
Normal file
@@ -0,0 +1,38 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from core.models import ContactAvailabilitySettings, User
|
||||
|
||||
|
||||
class AvailabilitySettingsPageTests(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user("avail-user", "avail@example.com", "x")
|
||||
self.client.force_login(self.user)
|
||||
|
||||
def test_get_page_renders(self):
|
||||
response = self.client.get(reverse("availability_settings"))
|
||||
self.assertEqual(200, response.status_code)
|
||||
self.assertContains(response, "Availability Settings")
|
||||
|
||||
def test_post_updates_settings(self):
|
||||
response = self.client.post(
|
||||
reverse("availability_settings"),
|
||||
{
|
||||
"enabled": "1",
|
||||
"show_in_chat": "1",
|
||||
"show_in_groups": "0",
|
||||
"inference_enabled": "1",
|
||||
"retention_days": "120",
|
||||
"fade_threshold_seconds": "300",
|
||||
},
|
||||
)
|
||||
self.assertEqual(200, response.status_code)
|
||||
row = ContactAvailabilitySettings.objects.get(user=self.user)
|
||||
self.assertTrue(row.enabled)
|
||||
self.assertTrue(row.show_in_chat)
|
||||
self.assertFalse(row.show_in_groups)
|
||||
self.assertTrue(row.inference_enabled)
|
||||
self.assertEqual(120, row.retention_days)
|
||||
self.assertEqual(300, row.fade_threshold_seconds)
|
||||
66
core/tests/test_backfill_contact_availability.py
Normal file
66
core/tests/test_backfill_contact_availability.py
Normal file
@@ -0,0 +1,66 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from django.core.management import call_command
|
||||
from django.test import TestCase
|
||||
|
||||
from core.models import (
|
||||
ChatSession,
|
||||
ContactAvailabilityEvent,
|
||||
Person,
|
||||
PersonIdentifier,
|
||||
Message,
|
||||
User,
|
||||
)
|
||||
from core.presence.inference import now_ms
|
||||
|
||||
|
||||
class BackfillContactAvailabilityCommandTests(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user("backfill-user", "backfill@example.com", "x")
|
||||
self.person = Person.objects.create(user=self.user, name="Backfill Person")
|
||||
self.identifier = PersonIdentifier.objects.create(
|
||||
user=self.user,
|
||||
person=self.person,
|
||||
service="signal",
|
||||
identifier="+15551234567",
|
||||
)
|
||||
self.session = ChatSession.objects.create(user=self.user, identifier=self.identifier)
|
||||
|
||||
def test_backfill_creates_message_and_read_receipt_availability_events(self):
|
||||
base_ts = now_ms()
|
||||
Message.objects.create(
|
||||
user=self.user,
|
||||
session=self.session,
|
||||
ts=base_ts,
|
||||
text="hello",
|
||||
source_service="signal",
|
||||
source_chat_id="+15551234567",
|
||||
custom_author="OTHER",
|
||||
)
|
||||
Message.objects.create(
|
||||
user=self.user,
|
||||
session=self.session,
|
||||
ts=base_ts + 1000,
|
||||
text="hey",
|
||||
source_service="signal",
|
||||
source_chat_id="+15551234567",
|
||||
custom_author="USER",
|
||||
read_ts=base_ts + 2000,
|
||||
read_by_identifier="+15551234567",
|
||||
)
|
||||
|
||||
call_command(
|
||||
"backfill_contact_availability",
|
||||
"--days",
|
||||
"36500",
|
||||
"--limit",
|
||||
"100",
|
||||
)
|
||||
|
||||
events = list(
|
||||
ContactAvailabilityEvent.objects.filter(user=self.user).order_by("ts", "source_kind")
|
||||
)
|
||||
self.assertEqual(3, len(events))
|
||||
self.assertTrue(any(row.source_kind == "message_in" for row in events))
|
||||
self.assertTrue(any(row.source_kind == "message_out" for row in events))
|
||||
self.assertTrue(any(row.source_kind == "read_receipt" for row in events))
|
||||
60
core/tests/test_codex_cli_provider.py
Normal file
60
core/tests/test_codex_cli_provider.py
Normal file
@@ -0,0 +1,60 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from subprocess import CompletedProcess, TimeoutExpired
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.test import SimpleTestCase
|
||||
|
||||
from core.tasks.providers.codex_cli import CodexCLITaskProvider
|
||||
|
||||
|
||||
class CodexCLITaskProviderTests(SimpleTestCase):
|
||||
def setUp(self):
|
||||
self.provider = CodexCLITaskProvider()
|
||||
|
||||
@patch("core.tasks.providers.codex_cli.subprocess.run")
|
||||
def test_healthcheck_success(self, run_mock):
|
||||
run_mock.return_value = CompletedProcess(
|
||||
args=["codex", "--version"],
|
||||
returncode=0,
|
||||
stdout="codex 1.2.3\n",
|
||||
stderr="",
|
||||
)
|
||||
result = self.provider.healthcheck({"command": "codex", "timeout_seconds": 5})
|
||||
self.assertTrue(result.ok)
|
||||
self.assertIn("codex", str(result.payload.get("stdout") or ""))
|
||||
|
||||
@patch("core.tasks.providers.codex_cli.subprocess.run")
|
||||
def test_create_task_builds_task_sync_command(self, run_mock):
|
||||
run_mock.return_value = CompletedProcess(
|
||||
args=[],
|
||||
returncode=0,
|
||||
stdout='{"external_key":"cx-123"}',
|
||||
stderr="",
|
||||
)
|
||||
result = self.provider.create_task(
|
||||
{
|
||||
"command": "codex",
|
||||
"workspace_root": "/tmp/work",
|
||||
"default_profile": "default",
|
||||
"timeout_seconds": 30,
|
||||
},
|
||||
{
|
||||
"task_id": "t1",
|
||||
"title": "hello",
|
||||
"reference_code": "42",
|
||||
},
|
||||
)
|
||||
self.assertTrue(result.ok)
|
||||
self.assertEqual("cx-123", result.external_key)
|
||||
args = run_mock.call_args.args[0]
|
||||
self.assertEqual(["codex", "task-sync", "--op", "create"], args[:4])
|
||||
self.assertIn("--workspace", args)
|
||||
self.assertIn("--payload-json", args)
|
||||
|
||||
@patch("core.tasks.providers.codex_cli.subprocess.run")
|
||||
def test_timeout_maps_to_failed_result(self, run_mock):
|
||||
run_mock.side_effect = TimeoutExpired(cmd=["codex"], timeout=10)
|
||||
result = self.provider.append_update({"command": "codex", "timeout_seconds": 10}, {"task_id": "t1"})
|
||||
self.assertFalse(result.ok)
|
||||
self.assertIn("timeout", result.error)
|
||||
140
core/tests/test_presence_engine.py
Normal file
140
core/tests/test_presence_engine.py
Normal file
@@ -0,0 +1,140 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
from core.models import (
|
||||
ContactAvailabilityEvent,
|
||||
ContactAvailabilitySettings,
|
||||
ContactAvailabilitySpan,
|
||||
Person,
|
||||
PersonIdentifier,
|
||||
User,
|
||||
)
|
||||
from core.presence.engine import (
|
||||
AvailabilitySignal,
|
||||
ensure_fading_state,
|
||||
record_inferred_signal,
|
||||
record_native_signal,
|
||||
)
|
||||
from core.presence.inference import now_ms
|
||||
|
||||
|
||||
class PresenceEngineTests(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user("presence-user", "presence@example.com", "x")
|
||||
self.person = Person.objects.create(user=self.user, name="Presence Person")
|
||||
self.identifier = PersonIdentifier.objects.create(
|
||||
user=self.user,
|
||||
person=self.person,
|
||||
service="signal",
|
||||
identifier="+15550001111",
|
||||
)
|
||||
ContactAvailabilitySettings.objects.update_or_create(
|
||||
user=self.user,
|
||||
defaults={
|
||||
"enabled": True,
|
||||
"show_in_chat": True,
|
||||
"show_in_groups": True,
|
||||
"inference_enabled": True,
|
||||
"retention_days": 90,
|
||||
"fade_threshold_seconds": 1,
|
||||
},
|
||||
)
|
||||
|
||||
def test_read_receipt_signal_creates_available_event(self):
|
||||
ts = now_ms()
|
||||
event = record_native_signal(
|
||||
AvailabilitySignal(
|
||||
user=self.user,
|
||||
person=self.person,
|
||||
person_identifier=self.identifier,
|
||||
service="signal",
|
||||
source_kind="read_receipt",
|
||||
availability_state="available",
|
||||
confidence=0.95,
|
||||
ts=ts,
|
||||
payload={"origin": "test"},
|
||||
)
|
||||
)
|
||||
self.assertIsNotNone(event)
|
||||
self.assertEqual(1, ContactAvailabilityEvent.objects.filter(user=self.user).count())
|
||||
self.assertEqual("available", event.availability_state)
|
||||
|
||||
def test_inactivity_transitions_to_fading(self):
|
||||
base_ts = now_ms()
|
||||
record_inferred_signal(
|
||||
AvailabilitySignal(
|
||||
user=self.user,
|
||||
person=self.person,
|
||||
person_identifier=self.identifier,
|
||||
service="signal",
|
||||
source_kind="read_receipt",
|
||||
availability_state="available",
|
||||
confidence=0.95,
|
||||
ts=base_ts,
|
||||
)
|
||||
)
|
||||
fade_event = ensure_fading_state(
|
||||
user=self.user,
|
||||
person=self.person,
|
||||
person_identifier=self.identifier,
|
||||
service="signal",
|
||||
at_ts=base_ts + 10_000,
|
||||
)
|
||||
self.assertIsNotNone(fade_event)
|
||||
self.assertEqual("fading", fade_event.availability_state)
|
||||
|
||||
def test_explicit_unavailable_blocks_fade_inference(self):
|
||||
base_ts = now_ms()
|
||||
record_native_signal(
|
||||
AvailabilitySignal(
|
||||
user=self.user,
|
||||
person=self.person,
|
||||
person_identifier=self.identifier,
|
||||
service="xmpp",
|
||||
source_kind="native_presence",
|
||||
availability_state="unavailable",
|
||||
confidence=1.0,
|
||||
ts=base_ts,
|
||||
)
|
||||
)
|
||||
fade_event = ensure_fading_state(
|
||||
user=self.user,
|
||||
person=self.person,
|
||||
person_identifier=self.identifier,
|
||||
service="xmpp",
|
||||
at_ts=base_ts + 60_000,
|
||||
)
|
||||
self.assertIsNone(fade_event)
|
||||
self.assertEqual(1, ContactAvailabilityEvent.objects.filter(user=self.user).count())
|
||||
|
||||
def test_adjacent_same_state_events_extend_single_span(self):
|
||||
ts0 = now_ms()
|
||||
record_native_signal(
|
||||
AvailabilitySignal(
|
||||
user=self.user,
|
||||
person=self.person,
|
||||
person_identifier=self.identifier,
|
||||
service="signal",
|
||||
source_kind="typing_start",
|
||||
availability_state="available",
|
||||
confidence=0.9,
|
||||
ts=ts0,
|
||||
)
|
||||
)
|
||||
record_native_signal(
|
||||
AvailabilitySignal(
|
||||
user=self.user,
|
||||
person=self.person,
|
||||
person_identifier=self.identifier,
|
||||
service="signal",
|
||||
source_kind="message_in",
|
||||
availability_state="available",
|
||||
confidence=0.8,
|
||||
ts=ts0 + 5_000,
|
||||
)
|
||||
)
|
||||
spans = list(ContactAvailabilitySpan.objects.filter(user=self.user).order_by("start_ts"))
|
||||
self.assertEqual(1, len(spans))
|
||||
self.assertEqual(ts0, spans[0].start_ts)
|
||||
self.assertEqual(ts0 + 5_000, spans[0].end_ts)
|
||||
50
core/tests/test_presence_query_and_compose_context.py
Normal file
50
core/tests/test_presence_query_and_compose_context.py
Normal file
@@ -0,0 +1,50 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
from core.models import Person, PersonIdentifier, User
|
||||
from core.presence import AvailabilitySignal, latest_state_for_people, record_native_signal
|
||||
from core.presence.inference import now_ms
|
||||
from core.views.compose import _context_base
|
||||
|
||||
|
||||
class PresenceQueryAndComposeContextTests(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user("ctx-user", "ctx@example.com", "x")
|
||||
self.person = Person.objects.create(user=self.user, name="Ctx Person")
|
||||
self.identifier = PersonIdentifier.objects.create(
|
||||
user=self.user,
|
||||
person=self.person,
|
||||
service="signal",
|
||||
identifier="15551234567",
|
||||
)
|
||||
|
||||
def test_latest_state_map_uses_string_person_keys(self):
|
||||
record_native_signal(
|
||||
AvailabilitySignal(
|
||||
user=self.user,
|
||||
person=self.person,
|
||||
person_identifier=self.identifier,
|
||||
service="signal",
|
||||
source_kind="message_in",
|
||||
availability_state="available",
|
||||
confidence=0.8,
|
||||
ts=now_ms(),
|
||||
)
|
||||
)
|
||||
state_map = latest_state_for_people(
|
||||
user=self.user,
|
||||
person_ids=[str(self.person.id)],
|
||||
service="signal",
|
||||
)
|
||||
self.assertIn(str(self.person.id), state_map)
|
||||
|
||||
def test_context_base_matches_signal_identifier_variants(self):
|
||||
base = _context_base(
|
||||
user=self.user,
|
||||
service="signal",
|
||||
identifier="+1 (555) 123-4567",
|
||||
person=self.person,
|
||||
)
|
||||
self.assertIsNotNone(base["person_identifier"])
|
||||
self.assertEqual(str(self.person.id), str(base["person"].id))
|
||||
31
core/tests/test_signal_text_extraction.py
Normal file
31
core/tests/test_signal_text_extraction.py
Normal file
@@ -0,0 +1,31 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from django.test import SimpleTestCase
|
||||
|
||||
from core.clients.signal import _extract_signal_text
|
||||
|
||||
|
||||
class SignalTextExtractionTests(SimpleTestCase):
|
||||
def test_extracts_emoji_only_data_message_text(self):
|
||||
payload = {
|
||||
"envelope": {
|
||||
"dataMessage": {
|
||||
"message": "🙂",
|
||||
}
|
||||
}
|
||||
}
|
||||
self.assertEqual("🙂", _extract_signal_text(payload, ""))
|
||||
|
||||
def test_extracts_sync_sent_message_fallback(self):
|
||||
payload = {
|
||||
"envelope": {
|
||||
"syncMessage": {
|
||||
"sentMessage": {
|
||||
"message": {
|
||||
"message": "ok 👍",
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
self.assertEqual("ok 👍", _extract_signal_text(payload, ""))
|
||||
198
core/tests/test_whatsapp_reaction_and_recalc.py
Normal file
198
core/tests/test_whatsapp_reaction_and_recalc.py
Normal file
@@ -0,0 +1,198 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from asgiref.sync import async_to_sync
|
||||
from django.core.management import call_command
|
||||
from django.test import TestCase
|
||||
|
||||
from core.clients.whatsapp import WhatsAppClient
|
||||
from core.messaging import history
|
||||
from core.models import (
|
||||
ChatSession,
|
||||
ContactAvailabilityEvent,
|
||||
ContactAvailabilitySpan,
|
||||
Message,
|
||||
Person,
|
||||
PersonIdentifier,
|
||||
User,
|
||||
)
|
||||
from core.presence.inference import now_ms
|
||||
|
||||
|
||||
class _DummyXMPPClient:
|
||||
async def apply_external_reaction(self, *args, **kwargs):
|
||||
return None
|
||||
|
||||
|
||||
class _DummyUR:
|
||||
def __init__(self, loop):
|
||||
self.loop = loop
|
||||
self.xmpp = type("X", (), {"client": _DummyXMPPClient()})()
|
||||
self.presence_calls = []
|
||||
self.stopped_typing_calls = []
|
||||
|
||||
async def presence_changed(self, protocol, *args, **kwargs):
|
||||
self.presence_calls.append((protocol, kwargs))
|
||||
|
||||
async def stopped_typing(self, protocol, *args, **kwargs):
|
||||
self.stopped_typing_calls.append((protocol, kwargs))
|
||||
|
||||
|
||||
class WhatsAppReactionHandlingTests(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user("wa-rx-user", "wa-rx@example.com", "x")
|
||||
self.person = Person.objects.create(user=self.user, name="WA Person")
|
||||
self.identifier = PersonIdentifier.objects.create(
|
||||
user=self.user,
|
||||
person=self.person,
|
||||
service="whatsapp",
|
||||
identifier="15551234567@s.whatsapp.net",
|
||||
)
|
||||
self.session = ChatSession.objects.create(user=self.user, identifier=self.identifier)
|
||||
self.base_ts = now_ms()
|
||||
self.target = Message.objects.create(
|
||||
user=self.user,
|
||||
session=self.session,
|
||||
ts=self.base_ts,
|
||||
text="hello",
|
||||
source_service="whatsapp",
|
||||
source_message_id="wa-target-1",
|
||||
source_chat_id="120363402761690215@g.us",
|
||||
sender_uuid="15551234567@s.whatsapp.net",
|
||||
)
|
||||
self.loop = asyncio.new_event_loop()
|
||||
self.ur = _DummyUR(self.loop)
|
||||
self.client = WhatsAppClient(self.ur, self.loop, "whatsapp")
|
||||
|
||||
def tearDown(self):
|
||||
try:
|
||||
self.loop.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def test_reaction_event_extract_and_apply_by_source_message_id(self):
|
||||
message_obj = {
|
||||
"reactionMessage": {
|
||||
"text": "👍",
|
||||
"targetMessageKey": {
|
||||
"id": "wa-target-1",
|
||||
"messageTimestamp": int(self.base_ts / 1000),
|
||||
},
|
||||
}
|
||||
}
|
||||
parsed = self.client._extract_reaction_event(message_obj)
|
||||
self.assertIsNotNone(parsed)
|
||||
self.assertEqual("wa-target-1", str(parsed.get("target_message_id") or ""))
|
||||
before_count = Message.objects.filter(user=self.user, session=self.session).count()
|
||||
async_to_sync(history.apply_reaction)(
|
||||
self.user,
|
||||
self.identifier,
|
||||
target_message_id="wa-target-1",
|
||||
target_ts=int(parsed.get("target_ts") or 0),
|
||||
emoji="👍",
|
||||
source_service="whatsapp",
|
||||
actor="15551234567@s.whatsapp.net",
|
||||
remove=False,
|
||||
payload={"event": "reaction"},
|
||||
)
|
||||
after_count = Message.objects.filter(user=self.user, session=self.session).count()
|
||||
self.assertEqual(before_count, after_count)
|
||||
|
||||
self.target.refresh_from_db()
|
||||
reactions = list((self.target.receipt_payload or {}).get("reactions") or [])
|
||||
self.assertEqual(1, len(reactions))
|
||||
self.assertEqual("👍", str(reactions[0].get("emoji") or ""))
|
||||
|
||||
def test_presence_event_emits_native_presence_with_last_seen(self):
|
||||
event = {
|
||||
"From": {"User": "15551234567@s.whatsapp.net"},
|
||||
"Unavailable": True,
|
||||
"LastSeen": int(self.base_ts / 1000),
|
||||
}
|
||||
async_to_sync(self.client._handle_presence_event)(event)
|
||||
self.assertTrue(self.ur.presence_calls)
|
||||
payload = self.ur.presence_calls[-1][1].get("payload") or {}
|
||||
self.assertEqual("offline", payload.get("presence"))
|
||||
self.assertTrue(int(payload.get("last_seen_ts") or 0) > 0)
|
||||
|
||||
|
||||
class RecalculateContactAvailabilityTests(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user("recalc-user", "recalc@example.com", "x")
|
||||
self.person = Person.objects.create(user=self.user, name="Recalc Person")
|
||||
self.identifier = PersonIdentifier.objects.create(
|
||||
user=self.user,
|
||||
person=self.person,
|
||||
service="whatsapp",
|
||||
identifier="15557654321@s.whatsapp.net",
|
||||
)
|
||||
self.session = ChatSession.objects.create(user=self.user, identifier=self.identifier)
|
||||
self.base_ts = now_ms()
|
||||
|
||||
Message.objects.create(
|
||||
user=self.user,
|
||||
session=self.session,
|
||||
ts=self.base_ts,
|
||||
text="task",
|
||||
source_service="whatsapp",
|
||||
source_message_id="m-1",
|
||||
source_chat_id="120363402761690215@g.us",
|
||||
sender_uuid="15557654321@s.whatsapp.net",
|
||||
custom_author="OTHER",
|
||||
read_ts=self.base_ts + 20_000,
|
||||
receipt_payload={
|
||||
"reactions": [
|
||||
{
|
||||
"emoji": "🔥",
|
||||
"actor": "15557654321@s.whatsapp.net",
|
||||
"source_service": "whatsapp",
|
||||
"removed": False,
|
||||
"updated_at": self.base_ts + 10_000,
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
def _projection(self):
|
||||
events = list(
|
||||
ContactAvailabilityEvent.objects.filter(user=self.user)
|
||||
.order_by("ts", "source_kind", "id")
|
||||
.values_list("service", "source_kind", "availability_state", "ts")
|
||||
)
|
||||
spans = list(
|
||||
ContactAvailabilitySpan.objects.filter(user=self.user)
|
||||
.order_by("start_ts", "end_ts", "id")
|
||||
.values_list("service", "state", "start_ts", "end_ts")
|
||||
)
|
||||
return events, spans
|
||||
|
||||
def test_recalculate_is_deterministic_and_no_skew_on_rerun(self):
|
||||
call_command("recalculate_contact_availability", "--days", "36500", "--limit", "500")
|
||||
first_events, first_spans = self._projection()
|
||||
self.assertTrue(first_events)
|
||||
self.assertTrue(first_spans)
|
||||
|
||||
call_command("recalculate_contact_availability", "--days", "36500", "--limit", "500")
|
||||
second_events, second_spans = self._projection()
|
||||
|
||||
self.assertEqual(first_events, second_events)
|
||||
self.assertEqual(first_spans, second_spans)
|
||||
|
||||
def test_recalculate_no_reset_does_not_duplicate(self):
|
||||
call_command("recalculate_contact_availability", "--days", "36500", "--limit", "500")
|
||||
events_before = ContactAvailabilityEvent.objects.filter(user=self.user).count()
|
||||
spans_before = ContactAvailabilitySpan.objects.filter(user=self.user).count()
|
||||
|
||||
call_command(
|
||||
"recalculate_contact_availability",
|
||||
"--days",
|
||||
"36500",
|
||||
"--limit",
|
||||
"500",
|
||||
"--no-reset",
|
||||
)
|
||||
events_after = ContactAvailabilityEvent.objects.filter(user=self.user).count()
|
||||
spans_after = ContactAvailabilitySpan.objects.filter(user=self.user).count()
|
||||
self.assertEqual(events_before, events_after)
|
||||
self.assertEqual(spans_before, spans_after)
|
||||
147
core/views/availability.py
Normal file
147
core/views/availability.py
Normal file
@@ -0,0 +1,147 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.shortcuts import render
|
||||
from django.views import View
|
||||
|
||||
from core.models import (
|
||||
ContactAvailabilityEvent,
|
||||
ContactAvailabilitySettings,
|
||||
ContactAvailabilitySpan,
|
||||
Person,
|
||||
)
|
||||
|
||||
|
||||
def _to_int(value, default=0):
|
||||
try:
|
||||
return int(value)
|
||||
except Exception:
|
||||
return int(default)
|
||||
|
||||
|
||||
def _to_bool(value, default=False):
|
||||
if value is None:
|
||||
return bool(default)
|
||||
text = str(value).strip().lower()
|
||||
if text in {"1", "true", "yes", "on", "y"}:
|
||||
return True
|
||||
if text in {"0", "false", "no", "off", "n"}:
|
||||
return False
|
||||
return bool(default)
|
||||
|
||||
|
||||
def _iso_to_ms(value: str) -> int:
|
||||
raw = str(value or "").strip()
|
||||
if not raw:
|
||||
return 0
|
||||
try:
|
||||
dt = datetime.fromisoformat(raw)
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
return int(dt.timestamp() * 1000)
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
class AvailabilitySettingsPage(LoginRequiredMixin, View):
|
||||
template_name = "pages/availability-settings.html"
|
||||
|
||||
def _settings(self, request):
|
||||
row, _ = ContactAvailabilitySettings.objects.get_or_create(user=request.user)
|
||||
return row
|
||||
|
||||
def post(self, request):
|
||||
row = self._settings(request)
|
||||
row.enabled = _to_bool(request.POST.get("enabled"), row.enabled)
|
||||
row.show_in_chat = _to_bool(request.POST.get("show_in_chat"), row.show_in_chat)
|
||||
row.show_in_groups = _to_bool(
|
||||
request.POST.get("show_in_groups"), row.show_in_groups
|
||||
)
|
||||
row.inference_enabled = _to_bool(
|
||||
request.POST.get("inference_enabled"), row.inference_enabled
|
||||
)
|
||||
row.retention_days = max(1, _to_int(request.POST.get("retention_days"), 90))
|
||||
row.fade_threshold_seconds = max(
|
||||
30, _to_int(request.POST.get("fade_threshold_seconds"), 900)
|
||||
)
|
||||
row.save(
|
||||
update_fields=[
|
||||
"enabled",
|
||||
"show_in_chat",
|
||||
"show_in_groups",
|
||||
"inference_enabled",
|
||||
"retention_days",
|
||||
"fade_threshold_seconds",
|
||||
"updated_at",
|
||||
]
|
||||
)
|
||||
return self.get(request)
|
||||
|
||||
def get(self, request):
|
||||
settings_row = self._settings(request)
|
||||
person_id = str(request.GET.get("person") or "").strip()
|
||||
service = str(request.GET.get("service") or "").strip().lower()
|
||||
state = str(request.GET.get("state") or "").strip().lower()
|
||||
source_kind = str(request.GET.get("source_kind") or "").strip().lower()
|
||||
start_ts = _iso_to_ms(request.GET.get("start"))
|
||||
end_ts = _iso_to_ms(request.GET.get("end"))
|
||||
if end_ts <= 0:
|
||||
end_ts = int(datetime.now(tz=timezone.utc).timestamp() * 1000)
|
||||
if start_ts <= 0:
|
||||
start_ts = max(0, end_ts - (14 * 24 * 60 * 60 * 1000))
|
||||
|
||||
events_qs = ContactAvailabilityEvent.objects.filter(user=request.user)
|
||||
spans_qs = ContactAvailabilitySpan.objects.filter(user=request.user)
|
||||
|
||||
if person_id:
|
||||
events_qs = events_qs.filter(person_id=person_id)
|
||||
spans_qs = spans_qs.filter(person_id=person_id)
|
||||
if service:
|
||||
events_qs = events_qs.filter(service=service)
|
||||
spans_qs = spans_qs.filter(service=service)
|
||||
if state:
|
||||
events_qs = events_qs.filter(availability_state=state)
|
||||
spans_qs = spans_qs.filter(state=state)
|
||||
if source_kind:
|
||||
events_qs = events_qs.filter(source_kind=source_kind)
|
||||
|
||||
events_qs = events_qs.filter(ts__gte=start_ts, ts__lte=end_ts)
|
||||
spans_qs = spans_qs.filter(start_ts__lte=end_ts, end_ts__gte=start_ts)
|
||||
|
||||
events = list(
|
||||
events_qs.select_related("person", "person_identifier").order_by("-ts")[:500]
|
||||
)
|
||||
spans = list(
|
||||
spans_qs.select_related("person", "person_identifier").order_by("-end_ts")[:500]
|
||||
)
|
||||
|
||||
people = list(Person.objects.filter(user=request.user).order_by("name"))
|
||||
|
||||
context = {
|
||||
"settings_row": settings_row,
|
||||
"people": people,
|
||||
"events": events,
|
||||
"spans": spans,
|
||||
"filters": {
|
||||
"person": person_id,
|
||||
"service": service,
|
||||
"state": state,
|
||||
"source_kind": source_kind,
|
||||
"start": request.GET.get("start") or "",
|
||||
"end": request.GET.get("end") or "",
|
||||
},
|
||||
"service_choices": ["signal", "whatsapp", "xmpp", "instagram", "web"],
|
||||
"state_choices": ["available", "fading", "unavailable", "unknown"],
|
||||
"source_kind_choices": [
|
||||
"native_presence",
|
||||
"read_receipt",
|
||||
"typing_start",
|
||||
"typing_stop",
|
||||
"message_in",
|
||||
"message_out",
|
||||
"inferred_timeout",
|
||||
],
|
||||
}
|
||||
return render(request, self.template_name, context)
|
||||
@@ -48,6 +48,8 @@ from core.models import (
|
||||
PlatformChatLink,
|
||||
WorkspaceConversation,
|
||||
)
|
||||
from core.presence import get_settings as get_availability_settings
|
||||
from core.presence import spans_for_range
|
||||
from core.realtime.typing_state import get_person_typing_state
|
||||
from core.translation.engine import process_inbound_translation
|
||||
from core.views.workspace import (
|
||||
@@ -101,6 +103,36 @@ def _default_service(service: str | None) -> str:
|
||||
return "signal"
|
||||
|
||||
|
||||
def _identifier_variants(service: str, identifier: str) -> list[str]:
|
||||
value = str(identifier or "").strip()
|
||||
if not value:
|
||||
return []
|
||||
service_key = _default_service(service)
|
||||
variants = [value]
|
||||
|
||||
bare = value.split("@", 1)[0].strip()
|
||||
if bare and bare not in variants:
|
||||
variants.append(bare)
|
||||
|
||||
if service_key == "signal":
|
||||
digits = re.sub(r"[^0-9]", "", value)
|
||||
if digits and digits not in variants:
|
||||
variants.append(digits)
|
||||
if digits:
|
||||
plus = f"+{digits}"
|
||||
if plus not in variants:
|
||||
variants.append(plus)
|
||||
elif service_key == "whatsapp":
|
||||
if bare:
|
||||
direct = f"{bare}@s.whatsapp.net"
|
||||
group = f"{bare}@g.us"
|
||||
if direct not in variants:
|
||||
variants.append(direct)
|
||||
if group not in variants:
|
||||
variants.append(group)
|
||||
return variants
|
||||
|
||||
|
||||
def _safe_limit(raw) -> int:
|
||||
try:
|
||||
value = int(raw or 40)
|
||||
@@ -136,6 +168,24 @@ def _format_ts_label(ts_value: int) -> str:
|
||||
return str(ts_value or "")
|
||||
|
||||
|
||||
def _serialize_availability_spans(spans):
|
||||
rows = []
|
||||
for row in list(spans or []):
|
||||
rows.append(
|
||||
{
|
||||
"id": int(getattr(row, "id", 0) or 0),
|
||||
"service": str(getattr(row, "service", "") or ""),
|
||||
"state": str(getattr(row, "state", "unknown") or "unknown"),
|
||||
"start_ts": int(getattr(row, "start_ts", 0) or 0),
|
||||
"end_ts": int(getattr(row, "end_ts", 0) or 0),
|
||||
"confidence_start": float(getattr(row, "confidence_start", 0.0) or 0.0),
|
||||
"confidence_end": float(getattr(row, "confidence_end", 0.0) or 0.0),
|
||||
"payload": dict(getattr(row, "payload", {}) or {}),
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def _is_outgoing(msg: Message) -> bool:
|
||||
is_outgoing = str(msg.custom_author or "").upper() in {"USER", "BOT"}
|
||||
if not is_outgoing:
|
||||
@@ -672,6 +722,11 @@ THREAD_METRIC_COPY_OVERRIDES = {
|
||||
def _workspace_conversation_for_person(user, person):
|
||||
if person is None:
|
||||
return None
|
||||
try:
|
||||
from core.views.workspace import _conversation_for_person
|
||||
|
||||
return _conversation_for_person(user, person)
|
||||
except Exception:
|
||||
return (
|
||||
WorkspaceConversation.objects.filter(
|
||||
user=user,
|
||||
@@ -1522,14 +1577,15 @@ def _engage_source_from_ref(plan, source_ref):
|
||||
|
||||
|
||||
def _context_base(user, service, identifier, person):
|
||||
identifier_variants = _identifier_variants(service, identifier)
|
||||
person_identifier = None
|
||||
if person is not None:
|
||||
if identifier:
|
||||
if identifier_variants:
|
||||
person_identifier = PersonIdentifier.objects.filter(
|
||||
user=user,
|
||||
person=person,
|
||||
service=service,
|
||||
identifier=identifier,
|
||||
identifier__in=identifier_variants,
|
||||
).first()
|
||||
if person_identifier is None:
|
||||
person_identifier = (
|
||||
@@ -1544,7 +1600,7 @@ def _context_base(user, service, identifier, person):
|
||||
person_identifier = PersonIdentifier.objects.filter(
|
||||
user=user,
|
||||
service=service,
|
||||
identifier=identifier,
|
||||
identifier__in=identifier_variants or [identifier],
|
||||
).first()
|
||||
|
||||
if person_identifier:
|
||||
@@ -2496,6 +2552,35 @@ def _panel_context(
|
||||
counterpart_identifiers=counterpart_identifiers,
|
||||
conversation=conversation,
|
||||
)
|
||||
availability_slices = []
|
||||
availability_enabled = False
|
||||
availability_settings = get_availability_settings(request.user)
|
||||
if (
|
||||
base["person"] is not None
|
||||
and availability_settings.enabled
|
||||
and availability_settings.show_in_chat
|
||||
):
|
||||
range_start = (
|
||||
int(session_bundle["messages"][0].ts or 0) if session_bundle["messages"] else 0
|
||||
)
|
||||
range_end = (
|
||||
int(session_bundle["messages"][-1].ts or 0) if session_bundle["messages"] else 0
|
||||
)
|
||||
if range_start <= 0 or range_end <= 0:
|
||||
now_ts = int(time.time() * 1000)
|
||||
range_start = now_ts - (24 * 60 * 60 * 1000)
|
||||
range_end = now_ts
|
||||
availability_enabled = True
|
||||
availability_slices = _serialize_availability_spans(
|
||||
spans_for_range(
|
||||
user=request.user,
|
||||
person=base["person"],
|
||||
start_ts=range_start,
|
||||
end_ts=range_end,
|
||||
service=base["service"],
|
||||
limit=200,
|
||||
)
|
||||
)
|
||||
glance_items = _build_glance_items(
|
||||
serialized_messages,
|
||||
person_id=(base["person"].id if base["person"] else None),
|
||||
@@ -2665,6 +2750,22 @@ def _panel_context(
|
||||
if base["person"]
|
||||
else reverse("ai_workspace")
|
||||
),
|
||||
"ai_workspace_graphs_url": (
|
||||
reverse(
|
||||
"ai_workspace_insight_graphs",
|
||||
kwargs={"type": "page", "person_id": base["person"].id},
|
||||
)
|
||||
if base["person"]
|
||||
else ""
|
||||
),
|
||||
"ai_workspace_info_url": (
|
||||
reverse(
|
||||
"ai_workspace_information",
|
||||
kwargs={"type": "page", "person_id": base["person"].id},
|
||||
)
|
||||
if base["person"]
|
||||
else ""
|
||||
),
|
||||
"ai_workspace_widget_url": (
|
||||
(
|
||||
f"{reverse('ai_workspace_person', kwargs={'type': 'widget', 'person_id': base['person'].id})}"
|
||||
@@ -2676,6 +2777,9 @@ def _panel_context(
|
||||
"manual_icon_class": "fa-solid fa-paper-plane",
|
||||
"panel_id": f"compose-panel-{unique}",
|
||||
"typing_state_json": json.dumps(typing_state),
|
||||
"availability_enabled": availability_enabled,
|
||||
"availability_slices": availability_slices,
|
||||
"availability_slices_json": json.dumps(availability_slices),
|
||||
"command_options": command_options,
|
||||
"bp_binding_summary": bp_binding_summary,
|
||||
"platform_options": platform_options,
|
||||
@@ -3133,6 +3237,31 @@ class ComposeThread(LoginRequiredMixin, View):
|
||||
counterpart_identifiers = _counterpart_identifiers_for_person(
|
||||
request.user, base["person"]
|
||||
)
|
||||
availability_slices = []
|
||||
availability_settings = get_availability_settings(request.user)
|
||||
if (
|
||||
base["person"] is not None
|
||||
and availability_settings.enabled
|
||||
and availability_settings.show_in_chat
|
||||
):
|
||||
range_start = (
|
||||
int(messages[0].ts or 0) if messages else max(0, int(after_ts or 0))
|
||||
)
|
||||
range_end = int(latest_ts or 0)
|
||||
if range_start <= 0 or range_end <= 0:
|
||||
now_ts = int(time.time() * 1000)
|
||||
range_start = now_ts - (24 * 60 * 60 * 1000)
|
||||
range_end = now_ts
|
||||
availability_slices = _serialize_availability_spans(
|
||||
spans_for_range(
|
||||
user=request.user,
|
||||
person=base["person"],
|
||||
start_ts=range_start,
|
||||
end_ts=range_end,
|
||||
service=base["service"],
|
||||
limit=200,
|
||||
)
|
||||
)
|
||||
payload = {
|
||||
"messages": _serialize_messages_with_artifacts(
|
||||
messages,
|
||||
@@ -3141,6 +3270,7 @@ class ComposeThread(LoginRequiredMixin, View):
|
||||
seed_previous=seed_previous,
|
||||
),
|
||||
"last_ts": latest_ts,
|
||||
"availability_slices": availability_slices,
|
||||
"typing": get_person_typing_state(
|
||||
user_id=request.user.id,
|
||||
person_id=base["person"].id if base["person"] else None,
|
||||
|
||||
@@ -12,6 +12,8 @@ from mixins.views import ObjectList, ObjectRead
|
||||
|
||||
from core.clients import transport
|
||||
from core.models import Chat, PersonIdentifier, PlatformChatLink
|
||||
from core.presence import get_settings as get_availability_settings
|
||||
from core.presence import latest_state_for_people
|
||||
from core.views.manage.permissions import SuperUserRequiredMixin
|
||||
|
||||
|
||||
@@ -211,6 +213,10 @@ class SignalChatsList(SuperUserRequiredMixin, ObjectList):
|
||||
|
||||
def get_queryset(self, *args, **kwargs):
|
||||
pk = self.kwargs.get("pk", "")
|
||||
availability_settings = get_availability_settings(self.request.user)
|
||||
show_availability = bool(
|
||||
availability_settings.enabled and availability_settings.show_in_groups
|
||||
)
|
||||
chats = list(
|
||||
Chat.objects.filter(
|
||||
Q(account=pk) | Q(account__isnull=True) | Q(account="")
|
||||
@@ -265,6 +271,9 @@ class SignalChatsList(SuperUserRequiredMixin, ObjectList):
|
||||
"person_name": (
|
||||
person_identifier.person.name if person_identifier else ""
|
||||
),
|
||||
"person_id": (
|
||||
str(person_identifier.person_id) if person_identifier else ""
|
||||
),
|
||||
"manual_icon_class": "fa-solid fa-paper-plane",
|
||||
"can_compose": bool(compose_page_url),
|
||||
"match_url": (
|
||||
@@ -300,6 +309,56 @@ class SignalChatsList(SuperUserRequiredMixin, ObjectList):
|
||||
}
|
||||
)
|
||||
|
||||
if show_availability:
|
||||
person_ids = [
|
||||
str(item.get("person_id") or "").strip()
|
||||
for item in rows
|
||||
if str(item.get("person_id") or "").strip()
|
||||
]
|
||||
person_ids = [pid for pid in person_ids if pid]
|
||||
state_map = latest_state_for_people(
|
||||
user=self.request.user,
|
||||
person_ids=person_ids,
|
||||
service="signal",
|
||||
)
|
||||
for row in rows:
|
||||
pid = str(row.get("person_id") or "").strip()
|
||||
if pid and pid in state_map:
|
||||
state_row = state_map.get(pid) or {}
|
||||
row["availability_state"] = str(state_row.get("state") or "unknown")
|
||||
row["availability_label"] = (
|
||||
f"{str(state_row.get('state') or 'unknown').title()} "
|
||||
f"({float(state_row.get('confidence') or 0.0):.2f})"
|
||||
)
|
||||
signal_person_ids = list(
|
||||
PersonIdentifier.objects.filter(
|
||||
user=self.request.user,
|
||||
service="signal",
|
||||
)
|
||||
.exclude(person_id__isnull=True)
|
||||
.values_list("person_id", flat=True)
|
||||
.distinct()
|
||||
)
|
||||
group_states = latest_state_for_people(
|
||||
user=self.request.user,
|
||||
person_ids=[str(pid) for pid in signal_person_ids if str(pid)],
|
||||
service="signal",
|
||||
)
|
||||
aggregate_counts = {"available": 0, "fading": 0}
|
||||
for state_row in group_states.values():
|
||||
state_text = str((state_row or {}).get("state") or "").strip().lower()
|
||||
if state_text in aggregate_counts:
|
||||
aggregate_counts[state_text] += 1
|
||||
aggregate_label = (
|
||||
f"{aggregate_counts['available']} available · {aggregate_counts['fading']} fading"
|
||||
if (aggregate_counts["available"] or aggregate_counts["fading"])
|
||||
else ""
|
||||
)
|
||||
if aggregate_label:
|
||||
for row in rows:
|
||||
if row.get("is_group"):
|
||||
row["availability_label"] = aggregate_label
|
||||
|
||||
return rows
|
||||
|
||||
|
||||
|
||||
@@ -24,8 +24,9 @@ from core.models import (
|
||||
PersonIdentifier,
|
||||
PlatformChatLink,
|
||||
Chat,
|
||||
ExternalChatLink,
|
||||
)
|
||||
from core.tasks.providers.mock import get_provider
|
||||
from core.tasks.providers import get_provider
|
||||
|
||||
SAFE_TASK_FLAGS_DEFAULTS = {
|
||||
"derive_enabled": True,
|
||||
@@ -170,6 +171,13 @@ def _service_label(service: str) -> str:
|
||||
return labels.get(key, key.title() if key else "Unknown")
|
||||
|
||||
|
||||
def _provider_row_map(user):
|
||||
return {
|
||||
str(row.provider or "").strip().lower(): row
|
||||
for row in TaskProviderConfig.objects.filter(user=user).order_by("provider")
|
||||
}
|
||||
|
||||
|
||||
def _resolve_channel_display(user, service: str, identifier: str) -> dict:
|
||||
service_key = str(service or "").strip().lower()
|
||||
raw_identifier = str(identifier or "").strip()
|
||||
@@ -408,12 +416,33 @@ class TaskSettings(LoginRequiredMixin, View):
|
||||
for row in sources:
|
||||
row.settings_effective = _flags_with_defaults(row.settings)
|
||||
row.allowed_prefixes_csv = ",".join(row.settings_effective["allowed_prefixes"])
|
||||
provider_map = _provider_row_map(request.user)
|
||||
codex_cfg = provider_map.get("codex_cli")
|
||||
codex_settings = dict(getattr(codex_cfg, "settings", {}) or {})
|
||||
mock_cfg = provider_map.get("mock")
|
||||
external_chat_links = list(
|
||||
ExternalChatLink.objects.filter(user=request.user).select_related(
|
||||
"person", "person_identifier"
|
||||
).order_by("-updated_at")[:200]
|
||||
)
|
||||
|
||||
return {
|
||||
"projects": projects,
|
||||
"epics": TaskEpic.objects.filter(project__user=request.user).select_related("project").order_by("project__name", "name"),
|
||||
"sources": sources,
|
||||
"patterns": TaskCompletionPattern.objects.filter(user=request.user).order_by("position", "created_at"),
|
||||
"provider_configs": TaskProviderConfig.objects.filter(user=request.user).order_by("provider"),
|
||||
"provider_configs": list(provider_map.values()),
|
||||
"mock_provider_config": mock_cfg,
|
||||
"codex_provider_config": codex_cfg,
|
||||
"codex_provider_settings": {
|
||||
"command": str(codex_settings.get("command") or "codex"),
|
||||
"workspace_root": str(codex_settings.get("workspace_root") or ""),
|
||||
"default_profile": str(codex_settings.get("default_profile") or ""),
|
||||
"timeout_seconds": int(codex_settings.get("timeout_seconds") or 60),
|
||||
"chat_link_mode": str(codex_settings.get("chat_link_mode") or "task-sync"),
|
||||
},
|
||||
"person_identifiers": PersonIdentifier.objects.filter(user=request.user).select_related("person").order_by("person__name", "service", "identifier")[:600],
|
||||
"external_chat_links": external_chat_links,
|
||||
"sync_events": ExternalSyncEvent.objects.filter(user=request.user).order_by("-updated_at")[:100],
|
||||
"prefill_service": prefill_service,
|
||||
"prefill_identifier": prefill_identifier,
|
||||
@@ -537,12 +566,75 @@ class TaskSettings(LoginRequiredMixin, View):
|
||||
defaults={"enabled": False, "settings": {}},
|
||||
)
|
||||
row.enabled = bool(request.POST.get("enabled"))
|
||||
row.save(update_fields=["enabled", "updated_at"])
|
||||
settings_payload = dict(row.settings or {})
|
||||
if provider == "codex_cli":
|
||||
timeout_raw = str(request.POST.get("timeout_seconds") or "60").strip()
|
||||
try:
|
||||
timeout_value = max(1, int(timeout_raw))
|
||||
except Exception:
|
||||
timeout_value = 60
|
||||
settings_payload = {
|
||||
"command": str(request.POST.get("command") or "codex").strip() or "codex",
|
||||
"workspace_root": str(request.POST.get("workspace_root") or "").strip(),
|
||||
"default_profile": str(request.POST.get("default_profile") or "").strip(),
|
||||
"timeout_seconds": timeout_value,
|
||||
"chat_link_mode": "task-sync",
|
||||
}
|
||||
row.settings = settings_payload
|
||||
row.save(update_fields=["enabled", "settings", "updated_at"])
|
||||
return _settings_redirect(request)
|
||||
|
||||
if action == "external_chat_link_upsert":
|
||||
provider = str(request.POST.get("provider") or "codex_cli").strip().lower() or "codex_cli"
|
||||
external_chat_id = str(request.POST.get("external_chat_id") or "").strip()
|
||||
person_identifier_id = str(request.POST.get("person_identifier_id") or "").strip()
|
||||
if not external_chat_id:
|
||||
messages.error(request, "External chat ID is required.")
|
||||
return _settings_redirect(request)
|
||||
identifier = None
|
||||
if person_identifier_id:
|
||||
identifier = get_object_or_404(
|
||||
PersonIdentifier,
|
||||
user=request.user,
|
||||
id=person_identifier_id,
|
||||
)
|
||||
row, _ = ExternalChatLink.objects.update_or_create(
|
||||
user=request.user,
|
||||
provider=provider,
|
||||
external_chat_id=external_chat_id,
|
||||
defaults={
|
||||
"person": getattr(identifier, "person", None),
|
||||
"person_identifier": identifier,
|
||||
"enabled": bool(request.POST.get("enabled")),
|
||||
"metadata": {
|
||||
"chat_link_mode": "task-sync",
|
||||
"notes": str(request.POST.get("metadata_notes") or "").strip(),
|
||||
},
|
||||
},
|
||||
)
|
||||
if identifier and row.person_id != identifier.person_id:
|
||||
row.person = identifier.person
|
||||
row.save(update_fields=["person", "updated_at"])
|
||||
return _settings_redirect(request)
|
||||
|
||||
if action == "external_chat_link_delete":
|
||||
row = get_object_or_404(
|
||||
ExternalChatLink,
|
||||
id=request.POST.get("external_link_id"),
|
||||
user=request.user,
|
||||
)
|
||||
row.delete()
|
||||
return _settings_redirect(request)
|
||||
|
||||
if action == "sync_retry":
|
||||
event = get_object_or_404(ExternalSyncEvent, id=request.POST.get("event_id"), user=request.user)
|
||||
provider = get_provider(event.provider)
|
||||
if bool(getattr(provider, "run_in_worker", False)):
|
||||
event.status = "pending"
|
||||
event.error = ""
|
||||
event.payload = dict(event.payload or {}, retried=True)
|
||||
event.save(update_fields=["status", "error", "payload", "updated_at"])
|
||||
else:
|
||||
payload = dict(event.payload or {})
|
||||
result = provider.append_update({}, payload)
|
||||
event.status = "ok" if result.ok else "failed"
|
||||
|
||||
@@ -8,6 +8,8 @@ from mixins.views import ObjectList, ObjectRead
|
||||
|
||||
from core.clients import transport
|
||||
from core.models import PersonIdentifier, PlatformChatLink
|
||||
from core.presence import get_settings as get_availability_settings
|
||||
from core.presence import latest_state_for_people
|
||||
from core.util import logs
|
||||
from core.views.compose import _compose_urls, _service_icon_class
|
||||
from core.views.manage.permissions import SuperUserRequiredMixin
|
||||
@@ -265,6 +267,10 @@ class WhatsAppChatsList(WhatsAppContactsList):
|
||||
def get_queryset(self, *args, **kwargs):
|
||||
rows = []
|
||||
seen = set()
|
||||
availability_settings = get_availability_settings(self.request.user)
|
||||
show_availability = bool(
|
||||
availability_settings.enabled and availability_settings.show_in_groups
|
||||
)
|
||||
state = transport.get_runtime_state("whatsapp")
|
||||
|
||||
runtime_contacts = state.get("contacts") or []
|
||||
@@ -414,7 +420,36 @@ class WhatsAppChatsList(WhatsAppContactsList):
|
||||
if not row.get("is_group") and row.get("identifier") in db_group_ids:
|
||||
row["is_group"] = True
|
||||
|
||||
return [row for row in rows if row.get("is_group")]
|
||||
group_rows = [row for row in rows if row.get("is_group")]
|
||||
if show_availability and group_rows:
|
||||
whatsapp_person_ids = list(
|
||||
PersonIdentifier.objects.filter(
|
||||
user=self.request.user,
|
||||
service="whatsapp",
|
||||
)
|
||||
.exclude(person_id__isnull=True)
|
||||
.values_list("person_id", flat=True)
|
||||
.distinct()
|
||||
)
|
||||
state_map = latest_state_for_people(
|
||||
user=self.request.user,
|
||||
person_ids=[str(pid) for pid in whatsapp_person_ids if str(pid)],
|
||||
service="whatsapp",
|
||||
)
|
||||
counts = {"available": 0, "fading": 0}
|
||||
for value in state_map.values():
|
||||
state_text = str((value or {}).get("state") or "").strip().lower()
|
||||
if state_text in counts:
|
||||
counts[state_text] += 1
|
||||
aggregate = (
|
||||
f"{counts['available']} available · {counts['fading']} fading"
|
||||
if (counts["available"] or counts["fading"])
|
||||
else ""
|
||||
)
|
||||
if aggregate:
|
||||
for row in group_rows:
|
||||
row["availability_label"] = aggregate
|
||||
return group_rows
|
||||
|
||||
|
||||
class WhatsAppAccountAdd(SuperUserRequiredMixin, ObjectRead):
|
||||
|
||||
@@ -3,21 +3,39 @@ set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
STACK_ENV="${STACK_ENV:-$ROOT_DIR/stack.env}"
|
||||
POD_NAME="gia"
|
||||
|
||||
REDIS_CONTAINER="redis_gia"
|
||||
SIGNAL_CONTAINER="signal"
|
||||
MIGRATION_CONTAINER="migration_gia"
|
||||
COLLECTSTATIC_CONTAINER="collectstatic_gia"
|
||||
APP_CONTAINER="gia"
|
||||
ASGI_CONTAINER="asgi_gia"
|
||||
UR_CONTAINER="ur_gia"
|
||||
SCHED_CONTAINER="scheduling_gia"
|
||||
STACK_ID="${GIA_STACK_ID:-${STACK_ID:-}}"
|
||||
STACK_ID="$(echo "$STACK_ID" | tr -cs 'a-zA-Z0-9._-' '-' | sed 's/^-*//; s/-*$//')"
|
||||
|
||||
name_with_stack() {
|
||||
local base="$1"
|
||||
if [[ -n "$STACK_ID" ]]; then
|
||||
echo "${base}_${STACK_ID}"
|
||||
else
|
||||
echo "$base"
|
||||
fi
|
||||
}
|
||||
|
||||
POD_NAME="$(name_with_stack "gia")"
|
||||
|
||||
REDIS_CONTAINER="$(name_with_stack "redis_gia")"
|
||||
SIGNAL_CONTAINER="$(name_with_stack "signal")"
|
||||
MIGRATION_CONTAINER="$(name_with_stack "migration_gia")"
|
||||
COLLECTSTATIC_CONTAINER="$(name_with_stack "collectstatic_gia")"
|
||||
APP_CONTAINER="$(name_with_stack "gia")"
|
||||
ASGI_CONTAINER="$(name_with_stack "asgi_gia")"
|
||||
UR_CONTAINER="$(name_with_stack "ur_gia")"
|
||||
SCHED_CONTAINER="$(name_with_stack "scheduling_gia")"
|
||||
CODEX_WORKER_CONTAINER="$(name_with_stack "codex_worker_gia")"
|
||||
|
||||
REDIS_DATA_DIR="${QUADLET_REDIS_DATA_DIR:-$ROOT_DIR/.podman/gia_redis_data}"
|
||||
WHATSAPP_DATA_DIR="${QUADLET_WHATSAPP_DATA_DIR:-$ROOT_DIR/.podman/gia_whatsapp_data}"
|
||||
SQLITE_DATA_DIR="${QUADLET_SQLITE_DATA_DIR:-$ROOT_DIR/.podman/gia_sqlite_data}"
|
||||
VRUN_DIR="/code/vrun"
|
||||
if [[ -n "${STACK_ID}" ]]; then
|
||||
VRUN_DIR="/code/vrun/${STACK_ID}"
|
||||
else
|
||||
VRUN_DIR="/code/vrun"
|
||||
fi
|
||||
|
||||
load_env() {
|
||||
set -a
|
||||
@@ -134,6 +152,7 @@ down_stack() {
|
||||
rm_if_exists "$ASGI_CONTAINER"
|
||||
rm_if_exists "$UR_CONTAINER"
|
||||
rm_if_exists "$SCHED_CONTAINER"
|
||||
rm_if_exists "$CODEX_WORKER_CONTAINER"
|
||||
}
|
||||
|
||||
start_stack() {
|
||||
@@ -153,7 +172,15 @@ start_stack() {
|
||||
chmod 0666 "$HOST_DATABASE_FILE" 2>/dev/null || true
|
||||
down_stack
|
||||
|
||||
podman pod create --name "$POD_NAME" -p "${APP_PORT:-5006}:8000" -p "8080:8080" >/dev/null
|
||||
local port_offset="${GIA_STACK_PORT_OFFSET:-}"
|
||||
if [[ -z "$port_offset" && -n "$STACK_ID" ]]; then
|
||||
port_offset="$(( $(printf '%s' "$STACK_ID" | cksum | awk '{print $1}') % 500 + 1 ))"
|
||||
fi
|
||||
port_offset="${port_offset:-0}"
|
||||
local app_port="${APP_PORT:-$((5006 + port_offset))}"
|
||||
local signal_port="${SIGNAL_PUBLIC_PORT:-$((8080 + port_offset))}"
|
||||
|
||||
podman pod create --name "$POD_NAME" -p "${app_port}:8000" -p "${signal_port}:8080" >/dev/null
|
||||
|
||||
podman run -d \
|
||||
--replace \
|
||||
@@ -182,6 +209,7 @@ start_stack() {
|
||||
run_worker_container "$ASGI_CONTAINER" "rm -f /var/run/asgi-gia.sock && . /venv/bin/activate && python -m pip install --disable-pip-version-check -q uvicorn && python -m uvicorn app.asgi:application --uds /var/run/asgi-gia.sock --workers 1" 0 1
|
||||
run_worker_container "$UR_CONTAINER" ". /venv/bin/activate && python manage.py ur" 1 1
|
||||
run_worker_container "$SCHED_CONTAINER" ". /venv/bin/activate && python manage.py scheduling" 1 0
|
||||
run_worker_container "$CODEX_WORKER_CONTAINER" ". /venv/bin/activate && python manage.py codex_worker" 1 0
|
||||
}
|
||||
|
||||
render_units() {
|
||||
@@ -211,18 +239,23 @@ case "${1:-}" in
|
||||
status)
|
||||
require_podman
|
||||
podman pod ps --format "table {{.Name}}\t{{.Status}}" | grep -E "^$POD_NAME\b" || true
|
||||
podman ps --format "table {{.Names}}\t{{.Status}}" | grep -E "^($APP_CONTAINER|$ASGI_CONTAINER|$UR_CONTAINER|$SCHED_CONTAINER|$REDIS_CONTAINER|$SIGNAL_CONTAINER)\b" || true
|
||||
podman ps --format "table {{.Names}}\t{{.Status}}" | grep -E "^($APP_CONTAINER|$ASGI_CONTAINER|$UR_CONTAINER|$SCHED_CONTAINER|$CODEX_WORKER_CONTAINER|$REDIS_CONTAINER|$SIGNAL_CONTAINER)\b" || true
|
||||
;;
|
||||
logs)
|
||||
require_podman
|
||||
if is_remote; then
|
||||
podman logs -f "$APP_CONTAINER"
|
||||
else
|
||||
podman logs -f "$APP_CONTAINER" "$ASGI_CONTAINER" "$UR_CONTAINER" "$SCHED_CONTAINER" "$REDIS_CONTAINER" "$SIGNAL_CONTAINER"
|
||||
podman logs -f "$APP_CONTAINER" "$ASGI_CONTAINER" "$UR_CONTAINER" "$SCHED_CONTAINER" "$CODEX_WORKER_CONTAINER" "$REDIS_CONTAINER" "$SIGNAL_CONTAINER"
|
||||
fi
|
||||
;;
|
||||
watch)
|
||||
require_podman
|
||||
load_env
|
||||
exec "$ROOT_DIR/scripts/quadlet/watchdog.sh"
|
||||
;;
|
||||
*)
|
||||
echo "Usage: $0 {install|up|down|restart|status|logs}" >&2
|
||||
echo "Usage: $0 {install|up|down|restart|status|logs|watch}" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
|
||||
@@ -45,6 +45,27 @@ def main() -> int:
|
||||
repo_root = Path(__file__).resolve().parents[2]
|
||||
stack_env_path = abs_from(repo_root, args.stack_env, "stack.env")
|
||||
env = parse_env(stack_env_path)
|
||||
stack_id = str(env.get("GIA_STACK_ID") or env.get("STACK_ID") or "").strip()
|
||||
stack_id = "".join(ch if (ch.isalnum() or ch in "._-") else "-" for ch in stack_id).strip("-")
|
||||
|
||||
def with_stack(base: str) -> str:
|
||||
return f"{base}_{stack_id}" if stack_id else base
|
||||
|
||||
unit_prefix = f"gia-{stack_id}" if stack_id else "gia"
|
||||
pod_ref = f"{unit_prefix}.pod"
|
||||
target_ref = f"{unit_prefix}.target"
|
||||
stack_offset_raw = str(env.get("GIA_STACK_PORT_OFFSET") or "").strip()
|
||||
if stack_offset_raw:
|
||||
try:
|
||||
stack_port_offset = max(0, int(stack_offset_raw))
|
||||
except Exception:
|
||||
stack_port_offset = 0
|
||||
elif stack_id:
|
||||
stack_port_offset = (sum(ord(ch) for ch in stack_id) % 500) + 1
|
||||
else:
|
||||
stack_port_offset = 0
|
||||
app_port = int(env.get("APP_PORT") or (5006 + stack_port_offset))
|
||||
signal_public_port = int(env.get("SIGNAL_PUBLIC_PORT") or (8080 + stack_port_offset))
|
||||
|
||||
repo_dir = abs_from(repo_root, env.get("REPO_DIR", "."), ".")
|
||||
host_uid = int(os.getuid())
|
||||
@@ -62,7 +83,7 @@ def main() -> int:
|
||||
redis_data_dir = abs_from(repo_root, env.get("QUADLET_REDIS_DATA_DIR", "./.podman/gia_redis_data"), "./.podman/gia_redis_data")
|
||||
whatsapp_data_dir = abs_from(repo_root, env.get("QUADLET_WHATSAPP_DATA_DIR", "./.podman/gia_whatsapp_data"), "./.podman/gia_whatsapp_data")
|
||||
|
||||
vrun_dir = Path("/code/vrun")
|
||||
vrun_dir = Path("/code/vrun") / stack_id if stack_id else Path("/code/vrun")
|
||||
signal_cli_dir = (repo_dir / "signal-cli-config").resolve()
|
||||
uwsgi_ini = (repo_dir / "docker" / "uwsgi.ini").resolve()
|
||||
redis_conf = (repo_dir / "docker" / "redis.conf").resolve()
|
||||
@@ -80,22 +101,24 @@ def main() -> int:
|
||||
|
||||
env_file = stack_env_path
|
||||
|
||||
pod_unit = """
|
||||
pod_unit = f"""
|
||||
[Unit]
|
||||
Description=GIA Pod
|
||||
|
||||
[Pod]
|
||||
PodName=gia
|
||||
PodName={with_stack('gia')}
|
||||
PublishPort={app_port}:8000
|
||||
PublishPort={signal_public_port}:8080
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
"""
|
||||
|
||||
target_unit = """
|
||||
target_unit = f"""
|
||||
[Unit]
|
||||
Description=GIA Stack Target
|
||||
Wants=gia-redis.service gia-signal.service gia-migration.service gia-collectstatic.service gia-app.service gia-asgi.service gia-ur.service gia-scheduling.service
|
||||
After=gia-redis.service gia-signal.service gia-migration.service gia-collectstatic.service
|
||||
Wants={unit_prefix}-redis.service {unit_prefix}-signal.service {unit_prefix}-migration.service {unit_prefix}-collectstatic.service {unit_prefix}-app.service {unit_prefix}-asgi.service {unit_prefix}-ur.service {unit_prefix}-scheduling.service {unit_prefix}-codex-worker.service
|
||||
After={unit_prefix}-redis.service {unit_prefix}-signal.service {unit_prefix}-migration.service {unit_prefix}-collectstatic.service
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
@@ -104,14 +127,14 @@ WantedBy=default.target
|
||||
redis_unit = f"""
|
||||
[Unit]
|
||||
Description=GIA Redis
|
||||
PartOf=gia.target
|
||||
PartOf={target_ref}
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Container]
|
||||
Image=docker.io/library/redis:latest
|
||||
ContainerName=redis_gia
|
||||
Pod=gia.pod
|
||||
ContainerName={with_stack('redis_gia')}
|
||||
Pod={pod_ref}
|
||||
Volume={redis_conf}:/etc/redis.conf:ro
|
||||
Volume={redis_data_dir}:/data
|
||||
Volume={vrun_dir}:/var/run
|
||||
@@ -122,20 +145,20 @@ Restart=always
|
||||
RestartSec=2
|
||||
|
||||
[Install]
|
||||
WantedBy=gia.target
|
||||
WantedBy={target_ref}
|
||||
"""
|
||||
|
||||
signal_unit = f"""
|
||||
[Unit]
|
||||
Description=GIA Signal API
|
||||
PartOf=gia.target
|
||||
PartOf={target_ref}
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Container]
|
||||
Image=docker.io/bbernhard/signal-cli-rest-api:latest
|
||||
ContainerName=signal
|
||||
Pod=gia.pod
|
||||
ContainerName={with_stack('signal')}
|
||||
Pod={pod_ref}
|
||||
Volume={signal_cli_dir}:/home/.local/share/signal-cli
|
||||
Environment=MODE=json-rpc
|
||||
|
||||
@@ -144,21 +167,21 @@ Restart=always
|
||||
RestartSec=2
|
||||
|
||||
[Install]
|
||||
WantedBy=gia.target
|
||||
WantedBy={target_ref}
|
||||
"""
|
||||
|
||||
def gia_container(name: str, container_name: str, command: str, include_uwsgi: bool, include_whatsapp: bool, after: str, requires: str, one_shot: bool = False) -> str:
|
||||
lines = [
|
||||
"[Unit]",
|
||||
f"Description={name}",
|
||||
"PartOf=gia.target",
|
||||
f"PartOf={target_ref}",
|
||||
f"After={after}",
|
||||
f"Requires={requires}",
|
||||
"",
|
||||
"[Container]",
|
||||
"Image=localhost/xf/gia:prod",
|
||||
f"ContainerName={container_name}",
|
||||
"Pod=gia.pod",
|
||||
f"Pod={pod_ref}",
|
||||
f"User={host_uid}:{host_gid}",
|
||||
f"EnvironmentFile={env_file}",
|
||||
"Environment=SIGNAL_HTTP_URL=http://127.0.0.1:8080",
|
||||
@@ -178,7 +201,7 @@ WantedBy=gia.target
|
||||
"Type=oneshot",
|
||||
"RemainAfterExit=yes",
|
||||
"TimeoutStartSec=0",
|
||||
"ExecStartPre=/bin/sh -c 'for i in $(seq 1 60); do [ -S /code/vrun/gia-redis.sock ] && exit 0; sleep 1; done; exit 1'",
|
||||
f"ExecStartPre=/bin/sh -c 'for i in $(seq 1 60); do [ -S {vrun_dir}/gia-redis.sock ] && exit 0; sleep 1; done; exit 1'",
|
||||
])
|
||||
else:
|
||||
lines.extend([
|
||||
@@ -186,80 +209,92 @@ WantedBy=gia.target
|
||||
"RestartSec=2",
|
||||
])
|
||||
lines.extend(["", "[Install]", "WantedBy=gia.target"])
|
||||
lines[-1] = f"WantedBy={target_ref}"
|
||||
return "\n".join(lines)
|
||||
|
||||
migration_unit = gia_container(
|
||||
"GIA Migration",
|
||||
"migration_gia",
|
||||
with_stack("migration_gia"),
|
||||
"sh -c '. /venv/bin/activate && python manage.py migrate --noinput'",
|
||||
include_uwsgi=False,
|
||||
include_whatsapp=False,
|
||||
after="gia-redis.service gia-signal.service",
|
||||
requires="gia-redis.service gia-signal.service",
|
||||
after=f"{unit_prefix}-redis.service {unit_prefix}-signal.service",
|
||||
requires=f"{unit_prefix}-redis.service {unit_prefix}-signal.service",
|
||||
one_shot=True,
|
||||
)
|
||||
|
||||
collectstatic_unit = gia_container(
|
||||
"GIA Collectstatic",
|
||||
"collectstatic_gia",
|
||||
with_stack("collectstatic_gia"),
|
||||
"sh -c '. /venv/bin/activate && python manage.py collectstatic --noinput'",
|
||||
include_uwsgi=False,
|
||||
include_whatsapp=False,
|
||||
after="gia-migration.service",
|
||||
requires="gia-migration.service",
|
||||
after=f"{unit_prefix}-migration.service",
|
||||
requires=f"{unit_prefix}-migration.service",
|
||||
one_shot=True,
|
||||
)
|
||||
|
||||
app_unit = gia_container(
|
||||
"GIA App",
|
||||
"gia",
|
||||
with_stack("gia"),
|
||||
"sh -c 'if [ \\\"$OPERATION\\\" = \\\"uwsgi\\\" ] ; then . /venv/bin/activate && uwsgi --ini /conf/uwsgi.ini ; else . /venv/bin/activate && exec python manage.py runserver 0.0.0.0:8000; fi'",
|
||||
include_uwsgi=True,
|
||||
include_whatsapp=True,
|
||||
after="gia-collectstatic.service gia-redis.service gia-signal.service",
|
||||
requires="gia-collectstatic.service gia-redis.service gia-signal.service",
|
||||
after=f"{unit_prefix}-collectstatic.service {unit_prefix}-redis.service {unit_prefix}-signal.service",
|
||||
requires=f"{unit_prefix}-collectstatic.service {unit_prefix}-redis.service {unit_prefix}-signal.service",
|
||||
)
|
||||
|
||||
asgi_unit = gia_container(
|
||||
"GIA ASGI",
|
||||
"asgi_gia",
|
||||
with_stack("asgi_gia"),
|
||||
"sh -c 'rm -f /var/run/asgi-gia.sock && . /venv/bin/activate && python -m pip install --disable-pip-version-check -q uvicorn && python -m uvicorn app.asgi:application --uds /var/run/asgi-gia.sock --workers 1'",
|
||||
include_uwsgi=False,
|
||||
include_whatsapp=True,
|
||||
after="gia-collectstatic.service gia-redis.service gia-signal.service",
|
||||
requires="gia-collectstatic.service gia-redis.service gia-signal.service",
|
||||
after=f"{unit_prefix}-collectstatic.service {unit_prefix}-redis.service {unit_prefix}-signal.service",
|
||||
requires=f"{unit_prefix}-collectstatic.service {unit_prefix}-redis.service {unit_prefix}-signal.service",
|
||||
)
|
||||
|
||||
ur_unit = gia_container(
|
||||
"GIA Unified Router",
|
||||
"ur_gia",
|
||||
with_stack("ur_gia"),
|
||||
"sh -c '. /venv/bin/activate && python manage.py ur'",
|
||||
include_uwsgi=True,
|
||||
include_whatsapp=True,
|
||||
after="gia-collectstatic.service gia-redis.service gia-signal.service",
|
||||
requires="gia-collectstatic.service gia-redis.service gia-signal.service",
|
||||
after=f"{unit_prefix}-collectstatic.service {unit_prefix}-redis.service {unit_prefix}-signal.service",
|
||||
requires=f"{unit_prefix}-collectstatic.service {unit_prefix}-redis.service {unit_prefix}-signal.service",
|
||||
)
|
||||
|
||||
scheduling_unit = gia_container(
|
||||
"GIA Scheduling",
|
||||
"scheduling_gia",
|
||||
with_stack("scheduling_gia"),
|
||||
"sh -c '. /venv/bin/activate && python manage.py scheduling'",
|
||||
include_uwsgi=True,
|
||||
include_whatsapp=False,
|
||||
after="gia-collectstatic.service gia-redis.service gia-signal.service",
|
||||
requires="gia-collectstatic.service gia-redis.service gia-signal.service",
|
||||
after=f"{unit_prefix}-collectstatic.service {unit_prefix}-redis.service {unit_prefix}-signal.service",
|
||||
requires=f"{unit_prefix}-collectstatic.service {unit_prefix}-redis.service {unit_prefix}-signal.service",
|
||||
)
|
||||
|
||||
write_unit(out_dir / "gia.pod", pod_unit)
|
||||
write_unit(out_dir / "gia.target", target_unit)
|
||||
write_unit(out_dir / "gia-redis.container", redis_unit)
|
||||
write_unit(out_dir / "gia-signal.container", signal_unit)
|
||||
write_unit(out_dir / "gia-migration.container", migration_unit)
|
||||
write_unit(out_dir / "gia-collectstatic.container", collectstatic_unit)
|
||||
write_unit(out_dir / "gia-app.container", app_unit)
|
||||
write_unit(out_dir / "gia-asgi.container", asgi_unit)
|
||||
write_unit(out_dir / "gia-ur.container", ur_unit)
|
||||
write_unit(out_dir / "gia-scheduling.container", scheduling_unit)
|
||||
codex_worker_unit = gia_container(
|
||||
"GIA Codex Worker",
|
||||
with_stack("codex_worker_gia"),
|
||||
"sh -c '. /venv/bin/activate && python manage.py codex_worker'",
|
||||
include_uwsgi=True,
|
||||
include_whatsapp=False,
|
||||
after=f"{unit_prefix}-collectstatic.service {unit_prefix}-redis.service {unit_prefix}-signal.service",
|
||||
requires=f"{unit_prefix}-collectstatic.service {unit_prefix}-redis.service {unit_prefix}-signal.service",
|
||||
)
|
||||
|
||||
write_unit(out_dir / f"{unit_prefix}.pod", pod_unit)
|
||||
write_unit(out_dir / f"{unit_prefix}.target", target_unit)
|
||||
write_unit(out_dir / f"{unit_prefix}-redis.container", redis_unit)
|
||||
write_unit(out_dir / f"{unit_prefix}-signal.container", signal_unit)
|
||||
write_unit(out_dir / f"{unit_prefix}-migration.container", migration_unit)
|
||||
write_unit(out_dir / f"{unit_prefix}-collectstatic.container", collectstatic_unit)
|
||||
write_unit(out_dir / f"{unit_prefix}-app.container", app_unit)
|
||||
write_unit(out_dir / f"{unit_prefix}-asgi.container", asgi_unit)
|
||||
write_unit(out_dir / f"{unit_prefix}-ur.container", ur_unit)
|
||||
write_unit(out_dir / f"{unit_prefix}-scheduling.container", scheduling_unit)
|
||||
write_unit(out_dir / f"{unit_prefix}-codex-worker.container", codex_worker_unit)
|
||||
|
||||
print(f"Wrote Quadlet units to: {out_dir}")
|
||||
return 0
|
||||
|
||||
82
scripts/quadlet/watchdog.sh
Executable file
82
scripts/quadlet/watchdog.sh
Executable file
@@ -0,0 +1,82 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
STACK_ENV="${STACK_ENV:-$ROOT_DIR/stack.env}"
|
||||
STACK_ID="${GIA_STACK_ID:-${STACK_ID:-}}"
|
||||
STACK_ID="$(echo "$STACK_ID" | tr -cs 'a-zA-Z0-9._-' '-' | sed 's/^-*//; s/-*$//')"
|
||||
SLEEP_SECONDS="${WATCHDOG_SLEEP_SECONDS:-15}"
|
||||
NTFY_TOPIC="${NTFY_TOPIC:-${NOTIFY_TOPIC:-}}"
|
||||
NTFY_URL_BASE="${NTFY_URL_BASE:-https://ntfy.sh}"
|
||||
HOST_TAG="${HOSTNAME:-$(hostname 2>/dev/null || echo unknown-host)}"
|
||||
|
||||
if [[ -f "$STACK_ENV" ]]; then
|
||||
set -a
|
||||
. "$STACK_ENV"
|
||||
set +a
|
||||
fi
|
||||
|
||||
name_with_stack() {
|
||||
local base="$1"
|
||||
if [[ -n "$STACK_ID" ]]; then
|
||||
echo "${base}_${STACK_ID}"
|
||||
else
|
||||
echo "$base"
|
||||
fi
|
||||
}
|
||||
|
||||
notify() {
|
||||
local title="$1"
|
||||
local msg="$2"
|
||||
if [[ -z "$NTFY_TOPIC" ]]; then
|
||||
return 0
|
||||
fi
|
||||
if ! command -v curl >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
curl -sS -X POST "${NTFY_URL_BASE%/}/$NTFY_TOPIC" \
|
||||
-H "Title: $title" \
|
||||
-H "Tags: warning" \
|
||||
-d "$msg" >/dev/null || true
|
||||
}
|
||||
|
||||
CONTAINERS=(
|
||||
"$(name_with_stack "gia")"
|
||||
"$(name_with_stack "asgi_gia")"
|
||||
"$(name_with_stack "ur_gia")"
|
||||
"$(name_with_stack "scheduling_gia")"
|
||||
"$(name_with_stack "codex_worker_gia")"
|
||||
)
|
||||
|
||||
declare -A LAST_STATE
|
||||
for name in "${CONTAINERS[@]}"; do
|
||||
LAST_STATE["$name"]="unknown"
|
||||
done
|
||||
|
||||
while true; do
|
||||
for name in "${CONTAINERS[@]}"; do
|
||||
running="false"
|
||||
if inspect_out="$(podman inspect -f '{{.State.Running}}' "$name" 2>/dev/null)"; then
|
||||
running="$(echo "$inspect_out" | tr -d '\n' | tr 'A-Z' 'a-z')"
|
||||
fi
|
||||
|
||||
if [[ "$running" == "true" ]]; then
|
||||
if [[ "${LAST_STATE[$name]}" != "up" ]]; then
|
||||
notify "GIA recovered: $name" "[$HOST_TAG] container $name is now running"
|
||||
fi
|
||||
LAST_STATE["$name"]="up"
|
||||
continue
|
||||
fi
|
||||
|
||||
restart_out=""
|
||||
if restart_out="$(podman restart "$name" 2>&1)"; then
|
||||
LAST_STATE["$name"]="recovering"
|
||||
notify "GIA restarted: $name" "[$HOST_TAG] container $name was not running and restart succeeded"
|
||||
else
|
||||
LAST_STATE["$name"]="down"
|
||||
notify "GIA restart failed: $name" "[$HOST_TAG] restart failed for $name: $restart_out"
|
||||
fi
|
||||
done
|
||||
|
||||
sleep "$SLEEP_SECONDS"
|
||||
done
|
||||
@@ -1,4 +1,9 @@
|
||||
APP_PORT=5006
|
||||
# Optional stack suffix for running a second isolated dev instance.
|
||||
# Example: GIA_STACK_ID=selfdev
|
||||
GIA_STACK_ID=
|
||||
# Optional deterministic port offset when STACK_ID is set.
|
||||
GIA_STACK_PORT_OFFSET=
|
||||
REPO_DIR=.
|
||||
APP_LOCAL_SETTINGS=./app/local_settings.py
|
||||
APP_DATABASE_FILE=./db.sqlite3
|
||||
@@ -6,6 +11,9 @@ DOMAIN=example.com
|
||||
URL=https://example.com
|
||||
ALLOWED_HOSTS=example.com
|
||||
NOTIFY_TOPIC=example-topic
|
||||
# Optional explicit ntfy topic/url for scripts/quadlet/watchdog.sh
|
||||
NTFY_TOPIC=
|
||||
NTFY_URL_BASE=https://ntfy.sh
|
||||
CSRF_TRUSTED_ORIGINS=https://example.com
|
||||
DEBUG=y
|
||||
SECRET_KEY=
|
||||
|
||||
Reference in New Issue
Block a user