Implement executing tasks

This commit is contained in:
2026-03-03 16:41:28 +00:00
parent d6bd56dace
commit 9c14e51b43
42 changed files with 3410 additions and 121 deletions

View File

@@ -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/",

View File

@@ -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

View File

@@ -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):

View 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}"
)
)

View 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

View 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}"
)
)

View File

@@ -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:

View 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"),
],
},
),
]

View File

@@ -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")

View File

@@ -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
View 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
View 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,
)
)

View 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
View 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

View File

@@ -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,
})

View File

@@ -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())

View File

@@ -13,6 +13,7 @@ class ProviderResult:
class TaskProvider:
name = "base"
run_in_worker = False
def healthcheck(self, config: dict) -> ProviderResult:
raise NotImplementedError

View 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)

View File

@@ -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"])

View File

@@ -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>

View 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 %}

View File

@@ -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 %}

View File

@@ -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>

View File

@@ -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.
}

View File

@@ -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 %}

View File

@@ -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>

View 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)

View 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))

View 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)

View 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)

View 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))

View 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, ""))

View 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
View 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)

View File

@@ -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,

View File

@@ -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

View File

@@ -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"

View File

@@ -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):

View File

@@ -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}"
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

View File

@@ -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
View 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

View File

@@ -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=