Lightweight containerized prosody tooling + moved auth scripts + xmpp reconnect/auth stabilization

This commit is contained in:
2026-03-05 02:18:12 +00:00
parent 0718a06c19
commit 2140c5facf
69 changed files with 3767 additions and 144 deletions

View File

@@ -30,6 +30,7 @@ from core.assist.engine import process_inbound_assist
from core.commands.base import CommandContext
from core.commands.engine import process_inbound_message
from core.commands.policies import ensure_variant_policies_for_profile
from core.events.ledger import append_event_sync
from core.messaging import ai as ai_runner
from core.messaging import history
from core.messaging import media_bridge
@@ -53,6 +54,7 @@ from core.models import (
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.transports.capabilities import supports, unsupported_reason
from core.translation.engine import process_inbound_translation
from core.views.workspace import (
INSIGHT_METRICS,
@@ -516,7 +518,8 @@ def _serialize_message(msg: Message) -> dict:
emoji = str(item.get("emoji") or "").strip()
if not emoji:
continue
actor = str(item.get("actor") or "").strip()
# Keep actor/source normalization stable to avoid duplicate/hiding issues.
actor = str(item.get("actor") or "").strip().lower()
source = str(item.get("source_service") or "").strip().lower()
key = (emoji, actor, source)
if key in seen_reactions:
@@ -1811,6 +1814,7 @@ def _reaction_actor_key(user_id, service: str) -> str:
def _resolve_reaction_target(message: Message, service: str, channel_identifier: str) -> dict:
service_key = _default_service(service)
message_source_service = str(getattr(message, "source_service", "") or "").strip().lower()
source_message_id = str(getattr(message, "source_message_id", "") or "").strip()
sender_uuid = str(getattr(message, "sender_uuid", "") or "").strip()
source_chat_id = str(getattr(message, "source_chat_id", "") or "").strip()
@@ -1819,23 +1823,23 @@ def _resolve_reaction_target(message: Message, service: str, channel_identifier:
if service_key == "signal":
target_ts = 0
if source_message_id.isdigit():
if message_source_service == "signal" and source_message_id.isdigit():
target_ts = int(source_message_id)
bridge_ref = _latest_signal_bridge_ref(message)
if not target_ts:
bridge_ref = _latest_signal_bridge_ref(message)
upstream_id = str(bridge_ref.get("upstream_message_id") or "").strip()
if upstream_id.isdigit():
target_ts = int(upstream_id)
if not target_ts:
target_ts = int(bridge_ref.get("upstream_ts") or 0)
if not target_ts:
# Local web messages are only reactable once bridge refs exist.
if not target_ts and message_source_service == "signal":
target_ts = delivered_ts or local_ts
if target_ts <= 0:
return {"error": "signal_target_unresolvable"}
target_author = sender_uuid
if not target_author:
bridge_ref = _latest_signal_bridge_ref(message)
target_author = str(bridge_ref.get("upstream_author") or "").strip()
if (
str(getattr(message, "custom_author", "") or "").strip().upper()
@@ -1856,10 +1860,10 @@ def _resolve_reaction_target(message: Message, service: str, channel_identifier:
}
if service_key == "whatsapp":
target_message_id = source_message_id
target_message_id = source_message_id if message_source_service == "whatsapp" else ""
target_ts = delivered_ts or local_ts
bridge_ref = _latest_whatsapp_bridge_ref(message)
if not target_message_id:
bridge_ref = _latest_whatsapp_bridge_ref(message)
target_message_id = str(bridge_ref.get("upstream_message_id") or "").strip()
if not target_ts:
target_ts = int(bridge_ref.get("upstream_ts") or 0)
@@ -4357,7 +4361,7 @@ class ComposeEngageSend(LoginRequiredMixin, View):
identifier=base["person_identifier"],
)
ts_value = int(ts) if str(ts).isdigit() else int(time.time() * 1000)
Message.objects.create(
created = Message.objects.create(
user=request.user,
session=session,
sender_uuid="",
@@ -4366,6 +4370,23 @@ class ComposeEngageSend(LoginRequiredMixin, View):
delivered_ts=ts_value if str(ts).isdigit() else None,
custom_author="USER",
)
try:
append_event_sync(
user=request.user,
session=session,
ts=ts_value,
event_type="message_created",
direction="out",
actor_identifier="USER",
origin_transport="web",
origin_message_id=str(created.id),
origin_chat_id=str(base["identifier"] or ""),
payload={"message_id": str(created.id), "text": outbound},
raw_payload={},
trace_id="",
)
except Exception:
pass
return JsonResponse({"ok": True, "message": "Shared engage sent."})
@@ -4519,6 +4540,23 @@ class ComposeSend(LoginRequiredMixin, View):
),
message_meta={},
)
try:
append_event_sync(
user=request.user,
session=session,
ts=int(ts),
event_type="message_created",
direction="out",
actor_identifier="USER",
origin_transport="web",
origin_message_id=str(created_message.id),
origin_chat_id=str(base["identifier"] or ""),
payload={"message_id": str(created_message.id), "text": text},
raw_payload={},
trace_id="",
)
except Exception:
pass
command_id = transport.enqueue_runtime_command(
base["service"],
"send_message_raw",
@@ -4591,6 +4629,23 @@ class ComposeSend(LoginRequiredMixin, View):
reply_source_message_id=str(reply_to.id) if reply_to is not None else None,
message_meta={},
)
try:
append_event_sync(
user=request.user,
session=session,
ts=msg_ts,
event_type="message_created",
direction="out",
actor_identifier="USER",
origin_transport="web",
origin_message_id=str(created_message.id),
origin_chat_id=str(base["identifier"] or ""),
payload={"message_id": str(created_message.id), "text": text},
raw_payload={},
trace_id="",
)
except Exception:
pass
if created_message is not None:
async_to_sync(process_inbound_message)(
CommandContext(
@@ -4643,6 +4698,14 @@ class ComposeReact(LoginRequiredMixin, View):
service_key = _default_service(service)
if service_key not in {"signal", "whatsapp"}:
return JsonResponse({"ok": False, "error": "service_not_supported"})
if bool(getattr(settings, "CAPABILITY_ENFORCEMENT_ENABLED", True)) and not supports(service_key, "reactions"):
return JsonResponse(
{
"ok": False,
"error": "unsupported_action",
"reason": unsupported_reason(service_key, "reactions"),
}
)
if not identifier and person is None:
return JsonResponse({"ok": False, "error": "missing_scope"})

View File

@@ -1,12 +1,15 @@
from django.http import JsonResponse
from django.shortcuts import render
from django.views import View
from core.models import (
AdapterHealthEvent,
AIRequest,
AIResult,
AIResultSignal,
Chat,
ChatSession,
ConversationEvent,
Group,
MemoryItem,
Message,
@@ -25,6 +28,8 @@ from core.models import (
WorkspaceConversation,
WorkspaceMetricSnapshot,
)
from core.events.projection import shadow_compare_session
from core.transports.capabilities import capability_snapshot
from core.views.manage.permissions import SuperUserRequiredMixin
@@ -37,6 +42,8 @@ class SystemSettings(SuperUserRequiredMixin, View):
"messages": Message.objects.filter(user=user).count(),
"queued_messages": QueuedMessage.objects.filter(user=user).count(),
"message_events": MessageEvent.objects.filter(user=user).count(),
"conversation_events": ConversationEvent.objects.filter(user=user).count(),
"adapter_health_events": AdapterHealthEvent.objects.filter(user=user).count(),
"workspace_conversations": WorkspaceConversation.objects.filter(
user=user
).count(),
@@ -85,6 +92,8 @@ class SystemSettings(SuperUserRequiredMixin, View):
conversation__user=user
).delete()[0]
deleted += MessageEvent.objects.filter(user=user).delete()[0]
deleted += ConversationEvent.objects.filter(user=user).delete()[0]
deleted += AdapterHealthEvent.objects.filter(user=user).delete()[0]
deleted += Message.objects.filter(user=user).delete()[0]
deleted += QueuedMessage.objects.filter(user=user).delete()[0]
deleted += WorkspaceConversation.objects.filter(user=user).delete()[0]
@@ -156,3 +165,97 @@ class SystemSettings(SuperUserRequiredMixin, View):
"notice_message": notice_message,
},
)
class ServiceCapabilitySnapshotAPI(SuperUserRequiredMixin, View):
def get(self, request):
service = str(request.GET.get("service") or "").strip().lower()
return JsonResponse(
{
"ok": True,
"data": capability_snapshot(service),
}
)
class AdapterHealthSummaryAPI(SuperUserRequiredMixin, View):
def get(self, request):
latest_by_service = {}
rows = AdapterHealthEvent.objects.order_by("service", "-ts")[:200]
for row in rows:
key = str(row.service or "").strip().lower()
if key in latest_by_service:
continue
latest_by_service[key] = {
"status": str(row.status or ""),
"reason": str(row.reason or ""),
"ts": int(row.ts or 0),
"created_at": row.created_at.isoformat(),
}
return JsonResponse({"ok": True, "services": latest_by_service})
class TraceDiagnosticsAPI(SuperUserRequiredMixin, View):
def get(self, request):
trace_id = str(request.GET.get("trace_id") or "").strip()
if not trace_id:
return JsonResponse(
{"ok": False, "error": "trace_id_required"},
status=400,
)
rows = list(
ConversationEvent.objects.filter(
user=request.user,
trace_id=trace_id,
)
.select_related("session")
.order_by("ts", "created_at")[:500]
)
return JsonResponse(
{
"ok": True,
"trace_id": trace_id,
"count": len(rows),
"events": [
{
"id": str(row.id),
"ts": int(row.ts or 0),
"event_type": str(row.event_type or ""),
"direction": str(row.direction or ""),
"session_id": str(row.session_id or ""),
"origin_transport": str(row.origin_transport or ""),
"origin_message_id": str(row.origin_message_id or ""),
"payload": dict(row.payload or {}),
}
for row in rows
],
}
)
class EventProjectionShadowAPI(SuperUserRequiredMixin, View):
def get(self, request):
session_id = str(request.GET.get("session_id") or "").strip()
if not session_id:
return JsonResponse(
{"ok": False, "error": "session_id_required"},
status=400,
)
detail_limit = int(request.GET.get("detail_limit") or 25)
session = ChatSession.objects.filter(
id=session_id,
user=request.user,
).first()
if session is None:
return JsonResponse(
{"ok": False, "error": "session_not_found"},
status=404,
)
compared = shadow_compare_session(session, detail_limit=max(0, detail_limit))
return JsonResponse(
{
"ok": True,
"result": compared,
"cause_summary": dict(compared.get("cause_counts") or {}),
}
)

View File

@@ -35,20 +35,14 @@ from core.models import (
ExternalChatLink,
)
from core.tasks.codex_support import resolve_external_chat_id
from core.tasks.chat_defaults import (
SAFE_TASK_FLAGS_DEFAULTS,
ensure_default_source_for_chat,
normalize_channel_identifier,
)
from core.tasks.codex_approval import queue_codex_event_with_pre_approval
from core.tasks.providers import get_provider
SAFE_TASK_FLAGS_DEFAULTS = {
"derive_enabled": True,
"match_mode": "strict",
"require_prefix": True,
"allowed_prefixes": ["task:", "todo:"],
"completion_enabled": True,
"ai_title_enabled": True,
"announce_task_id": False,
"min_chars": 3,
}
def _to_bool(raw, default=False) -> bool:
if raw is None:
return bool(default)
@@ -385,7 +379,7 @@ def _enqueue_codex_task_submission(
source_service=str(source_service or ""),
source_channel=str(source_channel or ""),
external_chat_id=external_chat_id,
status="queued",
status="waiting_approval",
request_payload={"action": "append_update", "provider_payload": dict(provider_payload)},
result_payload={},
error="",
@@ -396,51 +390,21 @@ def _enqueue_codex_task_submission(
idempotency_key = (
f"codex_submit:{task.id}:{mode}:{hashlib.sha1(str(command_text or '').encode('utf-8')).hexdigest()[:10]}:{run.id}"
)
ExternalSyncEvent.objects.update_or_create(
queue_codex_event_with_pre_approval(
user=user,
run=run,
task=task,
task_event=None,
action="append_update",
provider_payload=dict(provider_payload),
idempotency_key=idempotency_key,
defaults={
"user": user,
"task": task,
"task_event": None,
"provider": "codex_cli",
"status": "pending",
"payload": {
"action": "append_update",
"provider_payload": dict(provider_payload),
},
"error": "",
},
)
return run
def _normalize_channel_identifier(service: str, identifier: str) -> str:
service_key = str(service or "").strip().lower()
value = str(identifier or "").strip()
if not value:
return ""
if service_key == "whatsapp":
bare = value.split("@", 1)[0].strip()
if bare:
if value.endswith("@g.us"):
return f"{bare}@g.us"
if value.endswith("@s.whatsapp.net"):
return f"{bare}@s.whatsapp.net"
return f"{bare}@g.us"
if service_key == "signal":
return value
if service_key == "xmpp":
return value
if service_key == "instagram":
return value
if service_key == "web":
return value
return value
def _upsert_group_source(*, user, service: str, channel_identifier: str, project, epic=None):
normalized_service = str(service or "").strip().lower()
normalized_identifier = _normalize_channel_identifier(service, channel_identifier)
normalized_identifier = normalize_channel_identifier(service, channel_identifier)
if not normalized_service or not normalized_identifier:
return None
source, created = ChatTaskSource.objects.get_or_create(
@@ -503,6 +467,28 @@ def _notify_epic_created_in_project_chats(*, project: TaskProject, epic: TaskEpi
continue
def _reseed_chat_sources_for_deleted_project(user, service_channel_rows: list[tuple[str, str]]) -> int:
restored = 0
seen: set[tuple[str, str]] = set()
for service, channel_identifier in service_channel_rows:
service_key = str(service or "").strip().lower()
channel = str(channel_identifier or "").strip()
if not service_key or not channel:
continue
pair = (service_key, channel)
if pair in seen:
continue
seen.add(pair)
source = ensure_default_source_for_chat(
user=user,
service=service_key,
channel_identifier=channel,
)
if source is not None:
restored += 1
return restored
def _person_identifier_scope_variants(service: str, identifier: str) -> list[str]:
service_key = str(service or "").strip().lower()
raw_identifier = str(identifier or "").strip()
@@ -668,14 +654,17 @@ class TasksHub(LoginRequiredMixin, View):
def _context(self, request):
scope = self._scope(request)
projects = (
show_empty = bool(str(request.GET.get("show_empty") or "").strip() in {"1", "true", "yes", "on"})
all_projects = (
TaskProject.objects.filter(user=request.user)
.annotate(
task_count=Count("derived_tasks"),
epic_count=Count("epics", distinct=True),
source_count=Count("chat_sources", distinct=True),
)
.order_by("name")
)
projects = all_projects if show_empty else all_projects.filter(task_count__gt=0)
tasks = (
DerivedTask.objects.filter(user=request.user)
.select_related("project", "epic", "origin_message")
@@ -684,10 +673,7 @@ class TasksHub(LoginRequiredMixin, View):
tasks = _apply_task_creator_labels(request.user, tasks)
selected_project = None
if scope["selected_project_id"]:
selected_project = TaskProject.objects.filter(
user=request.user,
id=scope["selected_project_id"],
).first()
selected_project = all_projects.filter(id=scope["selected_project_id"]).first()
person_identifiers = []
person_identifier_rows = []
if scope["person"] is not None:
@@ -719,10 +705,12 @@ class TasksHub(LoginRequiredMixin, View):
)
return {
"projects": projects,
"project_choices": all_projects,
"tasks": tasks,
"scope": scope,
"person_identifier_rows": person_identifier_rows,
"selected_project": selected_project,
"show_empty_projects": show_empty,
}
def get(self, request):
@@ -802,9 +790,25 @@ class TasksHub(LoginRequiredMixin, View):
id=request.POST.get("project_id"),
user=request.user,
)
confirm_name = str(request.POST.get("confirm_name") or "").strip()
expected = str(project.name or "").strip()
if confirm_name != expected:
messages.error(
request,
f"Delete cancelled. Type the project name exactly to confirm deletion: {expected}",
)
return redirect("tasks_hub")
mapped_channels = list(project.chat_sources.values_list("service", "channel_identifier"))
deleted_name = str(project.name or "").strip() or "Project"
project.delete()
messages.success(request, f"Deleted project '{deleted_name}'.")
restored = _reseed_chat_sources_for_deleted_project(request.user, mapped_channels)
if restored > 0:
messages.success(
request,
f"Deleted project '{deleted_name}'. Restored {restored} chat mapping(s) with default projects.",
)
else:
messages.success(request, f"Deleted project '{deleted_name}'.")
return redirect("tasks_hub")
return redirect("tasks_hub")
@@ -891,9 +895,25 @@ class TaskProjectDetail(LoginRequiredMixin, View):
return redirect("tasks_project", project_id=str(project.id))
if action == "project_delete":
confirm_name = str(request.POST.get("confirm_name") or "").strip()
expected = str(project.name or "").strip()
if confirm_name != expected:
messages.error(
request,
f"Delete cancelled. Type the project name exactly to confirm deletion: {expected}",
)
return redirect("tasks_project", project_id=str(project.id))
mapped_channels = list(project.chat_sources.values_list("service", "channel_identifier"))
deleted_name = str(project.name or "").strip() or "Project"
project.delete()
messages.success(request, f"Deleted project '{deleted_name}'.")
restored = _reseed_chat_sources_for_deleted_project(request.user, mapped_channels)
if restored > 0:
messages.success(
request,
f"Deleted project '{deleted_name}'. Restored {restored} chat mapping(s) with default projects.",
)
else:
messages.success(request, f"Deleted project '{deleted_name}'.")
return redirect("tasks_hub")
return redirect("tasks_project", project_id=str(project.id))
@@ -928,6 +948,17 @@ class TaskGroupDetail(LoginRequiredMixin, View):
channel_identifier__in=variants,
).select_related("project", "epic")
mappings = list(mappings)
if not mappings:
seeded = ensure_default_source_for_chat(
user=request.user,
service=channel["service_key"],
channel_identifier=channel["display_identifier"],
)
if seeded is not None:
mappings = list(
ChatTaskSource.objects.filter(id=seeded.id)
.select_related("project", "epic")
)
for row in mappings:
row_channel = _resolve_channel_display(
request.user,
@@ -962,6 +993,7 @@ class TaskGroupDetail(LoginRequiredMixin, View):
"channel_display_name": channel["display_name"],
"projects": TaskProject.objects.filter(user=request.user).order_by("name"),
"mappings": mappings,
"primary_project": mappings[0].project if mappings else None,
"tasks": tasks,
},
)
@@ -1015,6 +1047,35 @@ class TaskGroupDetail(LoginRequiredMixin, View):
epic=epic,
)
messages.success(request, f"Mapped '{project.name}' to this group.")
elif action == "group_project_rename":
current = (
ChatTaskSource.objects.filter(
user=request.user,
service=channel["service_key"],
channel_identifier=channel["display_identifier"],
enabled=True,
)
.select_related("project")
.order_by("-updated_at")
.first()
)
if current is None:
current = ensure_default_source_for_chat(
user=request.user,
service=channel["service_key"],
channel_identifier=channel["display_identifier"],
)
new_name = str(request.POST.get("project_name") or "").strip()
if current is None or current.project is None:
messages.error(request, "No mapped project found for this chat.")
elif not new_name:
messages.error(request, "Project name is required.")
elif TaskProject.objects.filter(user=request.user, name=new_name).exclude(id=current.project_id).exists():
messages.error(request, f"Project '{new_name}' already exists.")
else:
current.project.name = new_name
current.project.save(update_fields=["name", "updated_at"])
messages.success(request, f"Renamed project to '{new_name}'.")
return redirect(
"tasks_group",
service=channel["service_key"],
@@ -1427,7 +1488,10 @@ class TaskCodexSubmit(LoginRequiredMixin, View):
mode="default",
source_message=getattr(task, "origin_message", None),
)
messages.success(request, f"Sent task #{task.reference_code} to Codex (run {run.id}).")
messages.success(
request,
f"Queued approval for task #{task.reference_code} before Codex run {run.id}.",
)
return redirect(next_url)
@@ -1524,28 +1588,49 @@ class CodexApprovalAction(LoginRequiredMixin, View):
"resolution_note",
]
)
if row.external_sync_event_id:
ExternalSyncEvent.objects.filter(id=row.external_sync_event_id).update(
status="ok",
error="",
)
run = row.codex_run
run.status = "approved_waiting_resume"
run.error = ""
run.save(update_fields=["status", "error", "updated_at"])
provider_payload = dict(run.request_payload.get("provider_payload") or {})
provider_payload.update(
{
"mode": "approval_response",
"approval_key": row.approval_key,
"resume_payload": dict(row.resume_payload or {}),
"codex_run_id": str(run.id),
}
)
resume_payload = dict(row.resume_payload or {})
resume_action = str(resume_payload.get("action") or "").strip().lower()
resume_provider_payload = dict(resume_payload.get("provider_payload") or {})
if resume_action and resume_provider_payload:
provider_payload = dict(resume_provider_payload)
provider_payload["codex_run_id"] = str(run.id)
event_action = resume_action
resume_idempotency_key = str(resume_payload.get("idempotency_key") or "").strip()
resume_event_key = (
resume_idempotency_key
if resume_idempotency_key
else f"codex_approval:{row.approval_key}:approved"
)
else:
provider_payload = dict(run.request_payload.get("provider_payload") or {})
provider_payload.update(
{
"mode": "approval_response",
"approval_key": row.approval_key,
"resume_payload": dict(row.resume_payload or {}),
"codex_run_id": str(run.id),
}
)
event_action = "append_update"
resume_event_key = f"codex_approval:{row.approval_key}:approved"
ExternalSyncEvent.objects.update_or_create(
idempotency_key=f"codex_approval:{row.approval_key}:approved",
idempotency_key=resume_event_key,
defaults={
"user": request.user,
"task": run.task,
"task_event": run.derived_task_event,
"provider": "codex_cli",
"status": "pending",
"payload": {"action": "append_update", "provider_payload": provider_payload},
"payload": {"action": event_action, "provider_payload": provider_payload},
"error": "",
},
)