Lightweight containerized prosody tooling + moved auth scripts + xmpp reconnect/auth stabilization
This commit is contained in:
@@ -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": "",
|
||||
},
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user