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

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