Lightweight containerized prosody tooling + moved auth scripts + xmpp reconnect/auth stabilization
This commit is contained in:
122
core/tasks/chat_defaults.py
Normal file
122
core/tasks/chat_defaults.py
Normal file
@@ -0,0 +1,122 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from core.models import ChatTaskSource, TaskProject
|
||||
from core.tasks.codex_support import channel_variants
|
||||
|
||||
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 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 not bare:
|
||||
return value
|
||||
if value.endswith("@s.whatsapp.net"):
|
||||
return f"{bare}@s.whatsapp.net"
|
||||
return f"{bare}@g.us"
|
||||
return value
|
||||
|
||||
|
||||
def resolve_message_scope(message) -> tuple[str, str]:
|
||||
source_service = str(getattr(message, "source_service", "") or "").strip().lower()
|
||||
source_channel = str(getattr(message, "source_chat_id", "") or "").strip()
|
||||
if source_service != "web":
|
||||
return source_service, source_channel
|
||||
identifier = getattr(getattr(message, "session", None), "identifier", None)
|
||||
fallback_service = str(getattr(identifier, "service", "") or "").strip().lower()
|
||||
fallback_identifier = str(getattr(identifier, "identifier", "") or "").strip()
|
||||
if fallback_service and fallback_identifier and fallback_service != "web":
|
||||
return fallback_service, fallback_identifier
|
||||
return source_service, source_channel
|
||||
|
||||
|
||||
def _project_name_candidate(service: str, channel_identifier: str, message=None) -> str:
|
||||
person_name = ""
|
||||
if message is not None:
|
||||
identifier = getattr(getattr(message, "session", None), "identifier", None)
|
||||
person = getattr(identifier, "person", None)
|
||||
person_name = str(getattr(person, "name", "") or "").strip()
|
||||
if person_name:
|
||||
return person_name[:255]
|
||||
raw = str(channel_identifier or "").strip()
|
||||
if str(service or "").strip().lower() == "whatsapp":
|
||||
raw = raw.split("@", 1)[0].strip()
|
||||
cleaned = re.sub(r"\s+", " ", raw).strip()
|
||||
if not cleaned:
|
||||
cleaned = "Chat"
|
||||
return f"Chat: {cleaned}"[:255]
|
||||
|
||||
|
||||
def _ensure_unique_project_name(user, base_name: str) -> str:
|
||||
base = str(base_name or "").strip() or "Chat"
|
||||
if not TaskProject.objects.filter(user=user, name=base).exists():
|
||||
return base
|
||||
idx = 2
|
||||
while idx < 10000:
|
||||
candidate = f"{base} ({idx})"[:255]
|
||||
if not TaskProject.objects.filter(user=user, name=candidate).exists():
|
||||
return candidate
|
||||
idx += 1
|
||||
return f"{base} ({str(user.id)[:8]})"[:255]
|
||||
|
||||
|
||||
def ensure_default_source_for_chat(
|
||||
*,
|
||||
user,
|
||||
service: str,
|
||||
channel_identifier: str,
|
||||
message=None,
|
||||
):
|
||||
service_key = str(service or "").strip().lower()
|
||||
normalized_identifier = normalize_channel_identifier(service_key, channel_identifier)
|
||||
variants = channel_variants(service_key, normalized_identifier)
|
||||
if not service_key or not variants:
|
||||
return None
|
||||
existing = (
|
||||
ChatTaskSource.objects.filter(
|
||||
user=user,
|
||||
service=service_key,
|
||||
channel_identifier__in=variants,
|
||||
)
|
||||
.select_related("project", "epic")
|
||||
.order_by("-enabled", "-updated_at", "-created_at")
|
||||
.first()
|
||||
)
|
||||
if existing is not None:
|
||||
if not existing.enabled:
|
||||
existing.enabled = True
|
||||
existing.save(update_fields=["enabled", "updated_at"])
|
||||
return existing
|
||||
project_name = _ensure_unique_project_name(
|
||||
user,
|
||||
_project_name_candidate(service_key, normalized_identifier, message=message),
|
||||
)
|
||||
project = TaskProject.objects.create(
|
||||
user=user,
|
||||
name=project_name,
|
||||
settings=dict(SAFE_TASK_FLAGS_DEFAULTS),
|
||||
)
|
||||
return ChatTaskSource.objects.create(
|
||||
user=user,
|
||||
service=service_key,
|
||||
channel_identifier=normalized_identifier,
|
||||
project=project,
|
||||
epic=None,
|
||||
enabled=True,
|
||||
settings=dict(SAFE_TASK_FLAGS_DEFAULTS),
|
||||
)
|
||||
91
core/tasks/codex_approval.py
Normal file
91
core/tasks/codex_approval.py
Normal file
@@ -0,0 +1,91 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
|
||||
from asgiref.sync import async_to_sync
|
||||
|
||||
from core.clients.transport import send_message_raw
|
||||
from core.models import CodexPermissionRequest, ExternalSyncEvent, TaskProviderConfig
|
||||
|
||||
|
||||
def _deterministic_approval_key(idempotency_key: str) -> str:
|
||||
digest = hashlib.sha1(str(idempotency_key or "").encode("utf-8")).hexdigest()[:12]
|
||||
return f"pre-{digest}"
|
||||
|
||||
|
||||
def queue_codex_event_with_pre_approval(
|
||||
*,
|
||||
user,
|
||||
run,
|
||||
task,
|
||||
task_event,
|
||||
action: str,
|
||||
provider_payload: dict,
|
||||
idempotency_key: str,
|
||||
) -> tuple[ExternalSyncEvent, CodexPermissionRequest]:
|
||||
approval_key = _deterministic_approval_key(idempotency_key)
|
||||
waiting_event, _ = ExternalSyncEvent.objects.update_or_create(
|
||||
idempotency_key=f"codex_waiting:{idempotency_key}",
|
||||
defaults={
|
||||
"user": user,
|
||||
"task": task,
|
||||
"task_event": task_event,
|
||||
"provider": "codex_cli",
|
||||
"status": "waiting_approval",
|
||||
"payload": {
|
||||
"action": str(action or "append_update"),
|
||||
"provider_payload": dict(provider_payload or {}),
|
||||
},
|
||||
"error": "",
|
||||
},
|
||||
)
|
||||
run.status = "waiting_approval"
|
||||
run.error = ""
|
||||
run.save(update_fields=["status", "error", "updated_at"])
|
||||
|
||||
request, _ = CodexPermissionRequest.objects.update_or_create(
|
||||
approval_key=approval_key,
|
||||
defaults={
|
||||
"user": user,
|
||||
"codex_run": run,
|
||||
"external_sync_event": waiting_event,
|
||||
"summary": "Pre-submit approval required before sending to Codex",
|
||||
"requested_permissions": {
|
||||
"type": "pre_submit",
|
||||
"provider": "codex_cli",
|
||||
"action": str(action or "append_update"),
|
||||
},
|
||||
"resume_payload": {
|
||||
"gate_type": "pre_submit",
|
||||
"action": str(action or "append_update"),
|
||||
"provider_payload": dict(provider_payload or {}),
|
||||
"idempotency_key": str(idempotency_key or ""),
|
||||
},
|
||||
"status": "pending",
|
||||
"resolved_at": None,
|
||||
"resolved_by_identifier": "",
|
||||
"resolution_note": "",
|
||||
},
|
||||
)
|
||||
|
||||
cfg = TaskProviderConfig.objects.filter(user=user, provider="codex_cli", enabled=True).first()
|
||||
settings_payload = dict(getattr(cfg, "settings", {}) or {})
|
||||
approver_service = str(settings_payload.get("approver_service") or "").strip().lower()
|
||||
approver_identifier = str(settings_payload.get("approver_identifier") or "").strip()
|
||||
if approver_service and approver_identifier:
|
||||
try:
|
||||
async_to_sync(send_message_raw)(
|
||||
approver_service,
|
||||
approver_identifier,
|
||||
text=(
|
||||
f"[codex approval] key={approval_key}\n"
|
||||
"summary=Pre-submit approval required before sending to Codex\n"
|
||||
"requested=pre_submit\n"
|
||||
f"use: .codex approve {approval_key} or .codex deny {approval_key}"
|
||||
),
|
||||
attachments=[],
|
||||
metadata={"origin_tag": f"codex-pre-approval:{approval_key}"},
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return waiting_event, request
|
||||
@@ -20,6 +20,8 @@ from core.models import (
|
||||
TaskEpic,
|
||||
TaskProviderConfig,
|
||||
)
|
||||
from core.tasks.chat_defaults import ensure_default_source_for_chat, resolve_message_scope
|
||||
from core.tasks.codex_approval import queue_codex_event_with_pre_approval
|
||||
from core.tasks.providers import get_provider
|
||||
from core.tasks.codex_support import resolve_external_chat_id
|
||||
|
||||
@@ -355,6 +357,17 @@ async def _emit_sync_event(task: DerivedTask, event: DerivedTaskEvent, action: s
|
||||
|
||||
# Worker-backed providers are queued and executed by `manage.py codex_worker`.
|
||||
if bool(getattr(provider, "run_in_worker", False)):
|
||||
if provider_name == "codex_cli":
|
||||
await sync_to_async(queue_codex_event_with_pre_approval)(
|
||||
user=task.user,
|
||||
run=codex_run,
|
||||
task=task,
|
||||
task_event=event,
|
||||
action=action,
|
||||
provider_payload=dict(request_payload),
|
||||
idempotency_key=idempotency_key,
|
||||
)
|
||||
return
|
||||
await sync_to_async(ExternalSyncEvent.objects.update_or_create)(
|
||||
idempotency_key=idempotency_key,
|
||||
defaults={
|
||||
@@ -526,6 +539,15 @@ async def _handle_epic_create_command(message: Message, sources: list[ChatTaskSo
|
||||
return True
|
||||
|
||||
|
||||
def _is_task_command_candidate(text: str) -> bool:
|
||||
body = str(text or "").strip()
|
||||
if not body:
|
||||
return False
|
||||
if _LIST_TASKS_RE.match(body) or _UNDO_TASK_RE.match(body) or _EPIC_CREATE_RE.match(body):
|
||||
return True
|
||||
return _has_task_prefix(body.lower(), ["task:", "todo:"])
|
||||
|
||||
|
||||
async def process_inbound_task_intelligence(message: Message) -> None:
|
||||
if message is None:
|
||||
return
|
||||
@@ -537,7 +559,20 @@ async def process_inbound_task_intelligence(message: Message) -> None:
|
||||
|
||||
sources = await _resolve_source_mappings(message)
|
||||
if not sources:
|
||||
return
|
||||
if not _is_task_command_candidate(text):
|
||||
return
|
||||
service, channel = resolve_message_scope(message)
|
||||
if not service or not channel:
|
||||
return
|
||||
seeded = await sync_to_async(ensure_default_source_for_chat)(
|
||||
user=message.user,
|
||||
service=service,
|
||||
channel_identifier=channel,
|
||||
message=message,
|
||||
)
|
||||
if seeded is None:
|
||||
return
|
||||
sources = [seeded]
|
||||
if await _handle_scope_task_commands(message, sources, text):
|
||||
return
|
||||
if await _handle_epic_create_command(message, sources, text):
|
||||
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
from hashlib import sha1
|
||||
|
||||
from .base import ProviderResult, TaskProvider
|
||||
|
||||
@@ -25,27 +26,106 @@ class CodexCLITaskProvider(TaskProvider):
|
||||
def _profile(self, config: dict) -> str:
|
||||
return str(config.get("default_profile") or "").strip()
|
||||
|
||||
def _is_task_sync_contract_mismatch(self, stderr: str) -> bool:
|
||||
text = str(stderr or "").lower()
|
||||
if "unexpected argument '--op'" in text:
|
||||
return True
|
||||
if "unexpected argument 'create'" in text and "usage: codex" in text:
|
||||
return True
|
||||
if "unexpected argument 'append_update'" in text and "usage: codex" in text:
|
||||
return True
|
||||
if "unexpected argument 'mark_complete'" in text and "usage: codex" in text:
|
||||
return True
|
||||
if "unexpected argument 'link_task'" in text and "usage: codex" in text:
|
||||
return True
|
||||
if "unrecognized subcommand 'create'" in text and "usage: codex" in text:
|
||||
return True
|
||||
if "unrecognized subcommand 'append_update'" in text and "usage: codex" in text:
|
||||
return True
|
||||
if "unrecognized subcommand 'mark_complete'" in text and "usage: codex" in text:
|
||||
return True
|
||||
return False
|
||||
|
||||
def _builtin_stub_result(self, op: str, payload: dict, stderr: str) -> ProviderResult:
|
||||
mode = str(payload.get("mode") or "default").strip().lower()
|
||||
external_key = (
|
||||
str(payload.get("external_key") or "").strip()
|
||||
or str(payload.get("task_id") or "").strip()
|
||||
)
|
||||
if mode == "approval_response":
|
||||
return ProviderResult(
|
||||
ok=True,
|
||||
external_key=external_key,
|
||||
payload={
|
||||
"op": op,
|
||||
"status": "ok",
|
||||
"summary": "approval acknowledged; resumed by builtin codex stub",
|
||||
"requires_approval": False,
|
||||
"output": "",
|
||||
"fallback_mode": "builtin_task_sync_stub",
|
||||
"fallback_reason": str(stderr or "")[:4000],
|
||||
},
|
||||
)
|
||||
task_id = str(payload.get("task_id") or "").strip()
|
||||
key_basis = f"{op}:{task_id}:{payload.get('trigger_message_id') or payload.get('origin_message_id') or ''}"
|
||||
approval_key = sha1(key_basis.encode("utf-8")).hexdigest()[:12]
|
||||
summary = "Codex approval required (builtin stub fallback)"
|
||||
return ProviderResult(
|
||||
ok=True,
|
||||
external_key=external_key,
|
||||
payload={
|
||||
"op": op,
|
||||
"status": "requires_approval",
|
||||
"requires_approval": True,
|
||||
"summary": summary,
|
||||
"approval_key": approval_key,
|
||||
"permission_request": {
|
||||
"summary": summary,
|
||||
"requested_permissions": ["workspace_write"],
|
||||
},
|
||||
"resume_payload": {
|
||||
"task_id": task_id,
|
||||
"op": op,
|
||||
},
|
||||
"fallback_mode": "builtin_task_sync_stub",
|
||||
"fallback_reason": str(stderr or "")[:4000],
|
||||
},
|
||||
)
|
||||
|
||||
def _run(self, config: dict, op: str, payload: dict) -> ProviderResult:
|
||||
cmd = [self._command(config), "task-sync", "--op", str(op)]
|
||||
base_cmd = [self._command(config), "task-sync"]
|
||||
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])
|
||||
common_args: list[str] = []
|
||||
if workspace:
|
||||
common_args.extend(["--workspace", workspace])
|
||||
if profile:
|
||||
common_args.extend(["--profile", profile])
|
||||
|
||||
primary_cmd = [*base_cmd, "--op", str(op), *common_args, "--payload-json", data]
|
||||
fallback_cmd = [*base_cmd, str(op), *common_args, "--payload-json", data]
|
||||
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
cmd,
|
||||
primary_cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=command_timeout,
|
||||
check=False,
|
||||
cwd=workspace if workspace else None,
|
||||
)
|
||||
stderr_probe = str(completed.stderr or "").lower()
|
||||
if completed.returncode != 0 and "unexpected argument '--op'" in stderr_probe:
|
||||
completed = subprocess.run(
|
||||
fallback_cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=command_timeout,
|
||||
check=False,
|
||||
cwd=workspace if workspace else None,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
return ProviderResult(
|
||||
ok=False,
|
||||
@@ -90,6 +170,8 @@ class CodexCLITaskProvider(TaskProvider):
|
||||
"requires_approval": requires_approval,
|
||||
}
|
||||
out_payload.update(parsed)
|
||||
if (not ok) and self._is_task_sync_contract_mismatch(stderr):
|
||||
return self._builtin_stub_result(op, dict(payload or {}), stderr)
|
||||
return ProviderResult(ok=ok, external_key=ext, error=("" if ok else stderr[:4000]), payload=out_payload)
|
||||
|
||||
def healthcheck(self, config: dict) -> ProviderResult:
|
||||
|
||||
Reference in New Issue
Block a user