from __future__ import annotations
import hashlib
import re
from asgiref.sync import sync_to_async
from django.utils import timezone
from core.commands.base import CommandContext, CommandHandler, CommandResult
from core.commands.delivery import post_status_in_source
from core.messaging.text_export import plain_text_blob
from core.models import (
ChatTaskSource,
CodexPermissionRequest,
CodexRun,
CommandProfile,
DerivedTask,
ExternalSyncEvent,
Message,
TaskProject,
TaskProviderConfig,
)
from core.tasks.codex_support import channel_variants, resolve_external_chat_id
_CODEX_DEFAULT_RE = re.compile(
r"^\s*(?:\.codex\b|#codex#?)(?P
.*)$",
re.IGNORECASE | re.DOTALL,
)
_CODEX_PLAN_RE = re.compile(
r"^\s*(?:\.codex\s+plan\b|#codex\s+plan#?)(?P.*)$",
re.IGNORECASE | re.DOTALL,
)
_CODEX_STATUS_RE = re.compile(r"^\s*(?:\.codex\s+status\b|#codex\s+status#?)\s*$", re.IGNORECASE)
_CODEX_APPROVE_DENY_RE = re.compile(
r"^\s*(?:\.codex|#codex)\s+(?Papprove|deny)\s+(?P[A-Za-z0-9._:-]+)#?\s*$",
re.IGNORECASE,
)
_PROJECT_TOKEN_RE = re.compile(r"\[\s*project\s*:\s*([^\]]+)\]", re.IGNORECASE)
_REFERENCE_RE = re.compile(r"(? str | None:
value = self.get("command")
return str(value) if value else None
@property
def body_text(self) -> str:
return str(self.get("body_text") or "")
@property
def approval_key(self) -> str:
return str(self.get("approval_key") or "")
def parse_codex_command(text: str) -> CodexParsedCommand:
body = str(text or "")
m = _CODEX_APPROVE_DENY_RE.match(body)
if m:
return CodexParsedCommand(
command=str(m.group("action") or "").strip().lower(),
body_text="",
approval_key=str(m.group("approval_key") or "").strip(),
)
if _CODEX_STATUS_RE.match(body):
return CodexParsedCommand(command="status", body_text="", approval_key="")
m = _CODEX_PLAN_RE.match(body)
if m:
return CodexParsedCommand(
command="plan",
body_text=str(m.group("body") or "").strip(),
approval_key="",
)
m = _CODEX_DEFAULT_RE.match(body)
if m:
return CodexParsedCommand(
command="default",
body_text=str(m.group("body") or "").strip(),
approval_key="",
)
return CodexParsedCommand(command=None, body_text="", approval_key="")
def codex_trigger_matches(message_text: str, trigger_token: str, exact_match_only: bool) -> bool:
body = str(message_text or "").strip()
parsed = parse_codex_command(body)
if parsed.command:
return True
trigger = str(trigger_token or "").strip()
if not trigger:
return False
if exact_match_only:
return body.lower() == trigger.lower()
return trigger.lower() in body.lower()
class CodexCommandHandler(CommandHandler):
slug = "codex"
async def _load_trigger(self, message_id: str) -> Message | None:
return await sync_to_async(
lambda: Message.objects.select_related("user", "session", "session__identifier", "reply_to")
.filter(id=message_id)
.first()
)()
def _effective_scope(self, trigger: Message) -> tuple[str, str]:
service = str(getattr(trigger, "source_service", "") or "").strip().lower()
channel = str(getattr(trigger, "source_chat_id", "") or "").strip()
identifier = getattr(getattr(trigger, "session", None), "identifier", None)
fallback_service = str(getattr(identifier, "service", "") or "").strip().lower()
fallback_identifier = str(getattr(identifier, "identifier", "") or "").strip()
if service == "web" and fallback_service and fallback_identifier and fallback_service != "web":
return fallback_service, fallback_identifier
return service or "web", channel
async def _mapped_sources(self, user, service: str, channel: str) -> list[ChatTaskSource]:
variants = channel_variants(service, channel)
if not variants:
return []
return await sync_to_async(list)(
ChatTaskSource.objects.filter(
user=user,
enabled=True,
service=service,
channel_identifier__in=variants,
).select_related("project", "epic")
)
async def _linked_task_from_reply(self, user, reply_to: Message | None) -> DerivedTask | None:
if reply_to is None:
return None
by_origin = await sync_to_async(
lambda: DerivedTask.objects.filter(user=user, origin_message=reply_to)
.select_related("project", "epic")
.order_by("-created_at")
.first()
)()
if by_origin is not None:
return by_origin
return await sync_to_async(
lambda: DerivedTask.objects.filter(user=user, events__source_message=reply_to)
.select_related("project", "epic")
.order_by("-created_at")
.first()
)()
def _extract_project_token(self, body_text: str) -> tuple[str, str]:
text = str(body_text or "")
m = _PROJECT_TOKEN_RE.search(text)
if not m:
return "", text
token = str(m.group(1) or "").strip()
cleaned = _PROJECT_TOKEN_RE.sub("", text).strip()
return token, cleaned
def _extract_reference(self, body_text: str) -> str:
m = _REFERENCE_RE.search(str(body_text or ""))
if not m:
return ""
return str(m.group(1) or "").strip()
async def _resolve_task(self, user, reference_code: str, reply_task: DerivedTask | None) -> DerivedTask | None:
if reference_code:
return await sync_to_async(
lambda: DerivedTask.objects.filter(user=user, reference_code=reference_code)
.select_related("project", "epic")
.order_by("-created_at")
.first()
)()
return reply_task
async def _resolve_project(
self,
*,
user,
service: str,
channel: str,
task: DerivedTask | None,
reply_task: DerivedTask | None,
project_token: str,
) -> tuple[TaskProject | None, str]:
if task is not None:
return task.project, ""
if reply_task is not None:
return reply_task.project, ""
if project_token:
project = await sync_to_async(
lambda: TaskProject.objects.filter(user=user, name__iexact=project_token).first()
)()
if project is not None:
return project, ""
return None, f"project_not_found:{project_token}"
mapped = await self._mapped_sources(user, service, channel)
project_ids = sorted({str(row.project_id) for row in mapped if row.project_id})
if len(project_ids) == 1:
project = next((row.project for row in mapped if str(row.project_id) == project_ids[0]), None)
return project, ""
if len(project_ids) > 1:
return None, "project_required:[project:Name]"
return None, "project_unresolved"
async def _post_source_status(self, trigger: Message, text: str, suffix: str) -> None:
await post_status_in_source(
trigger_message=trigger,
text=text,
origin_tag=f"codex-status:{suffix}",
)
async def _run_status(self, trigger: Message, service: str, channel: str, project: TaskProject | None) -> CommandResult:
def _load_runs():
qs = CodexRun.objects.filter(user=trigger.user)
if service:
qs = qs.filter(source_service=service)
if channel:
qs = qs.filter(source_channel=channel)
if project is not None:
qs = qs.filter(project=project)
return list(qs.order_by("-created_at")[:10])
runs = await sync_to_async(_load_runs)()
if not runs:
await self._post_source_status(trigger, "[codex] no recent runs for this scope.", "empty")
return CommandResult(ok=True, status="ok", payload={"count": 0})
lines = ["[codex] recent runs:"]
for row in runs:
ref = str(getattr(getattr(row, "task", None), "reference_code", "") or "-")
summary = str((row.result_payload or {}).get("summary") or "").strip()
summary_part = f" ยท {summary}" if summary else ""
lines.append(f"- {row.status} run={row.id} task=#{ref}{summary_part}")
await self._post_source_status(trigger, "\n".join(lines), "runs")
return CommandResult(ok=True, status="ok", payload={"count": len(runs)})
async def _run_approval_action(
self,
trigger: Message,
parsed: CodexParsedCommand,
current_service: str,
current_channel: str,
) -> CommandResult:
cfg = await sync_to_async(
lambda: TaskProviderConfig.objects.filter(user=trigger.user, provider="codex_cli").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 not approver_service or not approver_identifier:
return CommandResult(ok=False, status="failed", error="approver_channel_not_configured")
if str(current_service or "").strip().lower() != approver_service or str(current_channel or "").strip() not in set(
channel_variants(approver_service, approver_identifier)
):
return CommandResult(ok=False, status="failed", error="approval_command_not_allowed_in_this_channel")
approval_key = parsed.approval_key
request = await sync_to_async(
lambda: CodexPermissionRequest.objects.select_related("codex_run", "external_sync_event")
.filter(user=trigger.user, approval_key=approval_key)
.first()
)()
if request is None:
return CommandResult(ok=False, status="failed", error="approval_key_not_found")
now = timezone.now()
if parsed.command == "approve":
request.status = "approved"
request.resolved_at = now
request.resolved_by_identifier = current_channel
request.resolution_note = "approved via command"
await sync_to_async(request.save)(
update_fields=[
"status",
"resolved_at",
"resolved_by_identifier",
"resolution_note",
]
)
run = request.codex_run
run.status = "approved_waiting_resume"
run.error = ""
await sync_to_async(run.save)(update_fields=["status", "error", "updated_at"])
source_service = str(run.source_service or "")
source_channel = str(run.source_channel or "")
provider_payload = dict(run.request_payload.get("provider_payload") or {})
provider_payload.update(
{
"mode": "approval_response",
"approval_key": approval_key,
"resume_payload": dict(request.resume_payload or {}),
"codex_run_id": str(run.id),
"source_service": source_service,
"source_channel": source_channel,
}
)
await sync_to_async(ExternalSyncEvent.objects.update_or_create)(
idempotency_key=f"codex_approval:{approval_key}:approved",
defaults={
"user": trigger.user,
"task_id": run.task_id,
"task_event_id": run.derived_task_event_id,
"provider": "codex_cli",
"status": "pending",
"payload": {
"action": "append_update",
"provider_payload": provider_payload,
},
"error": "",
},
)
return CommandResult(ok=True, status="ok", payload={"approval_key": approval_key, "resolution": "approved"})
request.status = "denied"
request.resolved_at = now
request.resolved_by_identifier = current_channel
request.resolution_note = "denied via command"
await sync_to_async(request.save)(
update_fields=["status", "resolved_at", "resolved_by_identifier", "resolution_note"]
)
run = request.codex_run
run.status = "denied"
run.error = "approval_denied"
await sync_to_async(run.save)(update_fields=["status", "error", "updated_at"])
await sync_to_async(ExternalSyncEvent.objects.update_or_create)(
idempotency_key=f"codex_approval:{approval_key}:denied",
defaults={
"user": trigger.user,
"task_id": run.task_id,
"task_event_id": run.derived_task_event_id,
"provider": "codex_cli",
"status": "failed",
"payload": {
"action": "append_update",
"provider_payload": {
"mode": "approval_response",
"approval_key": approval_key,
"codex_run_id": str(run.id),
},
},
"error": "approval_denied",
},
)
return CommandResult(ok=True, status="ok", payload={"approval_key": approval_key, "resolution": "denied"})
async def _create_submission(
self,
*,
trigger: Message,
mode: str,
body_text: str,
task: DerivedTask,
project: TaskProject,
) -> CommandResult:
cfg = await sync_to_async(
lambda: TaskProviderConfig.objects.filter(user=trigger.user, provider="codex_cli", enabled=True).first()
)()
if cfg is None:
return CommandResult(ok=False, status="failed", error="provider_disabled_or_missing")
service, channel = self._effective_scope(trigger)
external_chat_id = await sync_to_async(resolve_external_chat_id)(
user=trigger.user,
provider="codex_cli",
service=service,
channel=channel,
)
payload = {
"task_id": str(task.id),
"reference_code": str(task.reference_code or ""),
"title": str(task.title or ""),
"external_key": str(task.external_key or ""),
"project_name": str(getattr(project, "name", "") or ""),
"epic_name": str(getattr(getattr(task, "epic", None), "name", "") or ""),
"source_service": service,
"source_channel": channel,
"external_chat_id": external_chat_id,
"origin_message_id": str(getattr(task, "origin_message_id", "") or ""),
"trigger_message_id": str(trigger.id),
"mode": mode,
"command_text": str(body_text or ""),
}
if mode == "plan":
anchor = trigger.reply_to
if anchor is None:
return CommandResult(ok=False, status="failed", error="reply_required_for_codex_plan")
rows = await sync_to_async(list)(
Message.objects.filter(
user=trigger.user,
session=trigger.session,
ts__gte=int(anchor.ts or 0),
ts__lte=int(trigger.ts or 0),
)
.order_by("ts")
.select_related("session", "session__identifier", "session__identifier__person")
)
payload["reply_context"] = {
"anchor_message_id": str(anchor.id),
"trigger_message_id": str(trigger.id),
"message_ids": [str(row.id) for row in rows],
"content": plain_text_blob(rows),
}
run = await sync_to_async(CodexRun.objects.create)(
user=trigger.user,
task=task,
source_message=trigger,
project=project,
epic=getattr(task, "epic", None),
source_service=service,
source_channel=channel,
external_chat_id=external_chat_id,
status="queued",
request_payload={"action": "append_update", "provider_payload": dict(payload)},
result_payload={},
error="",
)
payload["codex_run_id"] = str(run.id)
run.request_payload = {"action": "append_update", "provider_payload": dict(payload)}
await sync_to_async(run.save)(update_fields=["request_payload", "updated_at"])
idempotency_key = f"codex_cmd:{trigger.id}:{mode}:{task.id}:{hashlib.sha1(str(body_text or '').encode('utf-8')).hexdigest()[:12]}"
await sync_to_async(ExternalSyncEvent.objects.update_or_create)(
idempotency_key=idempotency_key,
defaults={
"user": trigger.user,
"task": task,
"task_event": None,
"provider": "codex_cli",
"status": "pending",
"payload": {
"action": "append_update",
"provider_payload": dict(payload),
},
"error": "",
},
)
return CommandResult(ok=True, status="ok", payload={"codex_run_id": str(run.id)})
async def execute(self, ctx: CommandContext) -> CommandResult:
trigger = await self._load_trigger(ctx.message_id)
if trigger is None:
return CommandResult(ok=False, status="failed", error="trigger_not_found")
profile = await sync_to_async(
lambda: CommandProfile.objects.filter(user=trigger.user, slug=self.slug, enabled=True).first()
)()
if profile is None:
return CommandResult(ok=False, status="skipped", error="profile_missing")
parsed = parse_codex_command(ctx.message_text)
if not parsed.command:
return CommandResult(ok=False, status="skipped", error="codex_command_not_matched")
service, channel = self._effective_scope(trigger)
if parsed.command == "status":
project = None
reply_task = await self._linked_task_from_reply(trigger.user, trigger.reply_to)
if reply_task is not None:
project = reply_task.project
return await self._run_status(trigger, service, channel, project)
if parsed.command in {"approve", "deny"}:
return await self._run_approval_action(
trigger,
parsed,
current_service=str(ctx.service or ""),
current_channel=str(ctx.channel_identifier or ""),
)
project_token, cleaned_body = self._extract_project_token(parsed.body_text)
reference_code = self._extract_reference(cleaned_body)
reply_task = await self._linked_task_from_reply(trigger.user, trigger.reply_to)
task = await self._resolve_task(trigger.user, reference_code, reply_task)
if task is None:
return CommandResult(ok=False, status="failed", error="task_target_required")
project, project_error = await self._resolve_project(
user=trigger.user,
service=service,
channel=channel,
task=task,
reply_task=reply_task,
project_token=project_token,
)
if project is None:
return CommandResult(ok=False, status="failed", error=project_error or "project_unresolved")
mode = "plan" if parsed.command == "plan" else "default"
return await self._create_submission(
trigger=trigger,
mode=mode,
body_text=cleaned_body,
task=task,
project=project,
)