Implement plans
This commit is contained in:
@@ -1,18 +1,20 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from asgiref.sync import sync_to_async
|
||||
from django.conf import settings
|
||||
|
||||
from core.commands.base import CommandContext, CommandResult
|
||||
from core.commands.handlers.bp import (
|
||||
BPCommandHandler,
|
||||
bp_reply_is_optional_for_trigger,
|
||||
bp_subcommands_enabled,
|
||||
bp_trigger_matches,
|
||||
)
|
||||
from core.commands.handlers.codex import CodexCommandHandler, codex_trigger_matches
|
||||
from core.commands.policies import ensure_variant_policies_for_profile
|
||||
from core.commands.registry import get as get_handler
|
||||
from core.commands.registry import register
|
||||
from core.messaging.reply_sync import is_mirrored_origin
|
||||
from core.models import CommandChannelBinding, CommandProfile, Message
|
||||
from core.models import CommandAction, CommandChannelBinding, CommandProfile, Message
|
||||
from core.util import logs
|
||||
|
||||
log = logs.get_logger("command_engine")
|
||||
@@ -36,17 +38,178 @@ def _channel_variants(service: str, channel_identifier: str) -> list[str]:
|
||||
return variants
|
||||
|
||||
|
||||
def _canonical_channel_identifier(service: str, channel_identifier: str) -> str:
|
||||
value = str(channel_identifier or "").strip()
|
||||
if not value:
|
||||
return ""
|
||||
if str(service or "").strip().lower() == "whatsapp":
|
||||
return value.split("@", 1)[0].strip()
|
||||
return value
|
||||
|
||||
|
||||
def _effective_bootstrap_scope(
|
||||
ctx: CommandContext,
|
||||
trigger_message: Message,
|
||||
) -> tuple[str, str]:
|
||||
service = str(ctx.service or "").strip().lower()
|
||||
identifier = str(ctx.channel_identifier or "").strip()
|
||||
if service != "web":
|
||||
return service, identifier
|
||||
session_identifier = getattr(getattr(trigger_message, "session", None), "identifier", None)
|
||||
fallback_service = str(getattr(session_identifier, "service", "") or "").strip().lower()
|
||||
fallback_identifier = str(getattr(session_identifier, "identifier", "") or "").strip()
|
||||
if fallback_service and fallback_identifier and fallback_service != "web":
|
||||
return fallback_service, fallback_identifier
|
||||
return service, identifier
|
||||
|
||||
|
||||
def _ensure_bp_profile(user_id: int) -> CommandProfile:
|
||||
profile, _ = CommandProfile.objects.get_or_create(
|
||||
user_id=user_id,
|
||||
slug="bp",
|
||||
defaults={
|
||||
"name": "Business Plan",
|
||||
"enabled": True,
|
||||
"trigger_token": ".bp",
|
||||
"reply_required": True,
|
||||
"exact_match_only": True,
|
||||
"window_scope": "conversation",
|
||||
"visibility_mode": "status_in_source",
|
||||
},
|
||||
)
|
||||
updated = False
|
||||
if not profile.enabled:
|
||||
profile.enabled = True
|
||||
updated = True
|
||||
if updated:
|
||||
profile.save(update_fields=["enabled", "updated_at"])
|
||||
if str(profile.trigger_token or "").strip() != ".bp":
|
||||
profile.trigger_token = ".bp"
|
||||
profile.save(update_fields=["trigger_token", "updated_at"])
|
||||
for action_type, position in (("extract_bp", 0), ("save_document", 1), ("post_result", 2)):
|
||||
action, created = CommandAction.objects.get_or_create(
|
||||
profile=profile,
|
||||
action_type=action_type,
|
||||
defaults={"enabled": True, "position": position},
|
||||
)
|
||||
if (not created) and (not action.enabled):
|
||||
action.enabled = True
|
||||
action.save(update_fields=["enabled", "updated_at"])
|
||||
ensure_variant_policies_for_profile(profile)
|
||||
return profile
|
||||
|
||||
|
||||
def _ensure_codex_profile(user_id: int) -> CommandProfile:
|
||||
profile, _ = CommandProfile.objects.get_or_create(
|
||||
user_id=user_id,
|
||||
slug="codex",
|
||||
defaults={
|
||||
"name": "Codex",
|
||||
"enabled": True,
|
||||
"trigger_token": ".codex",
|
||||
"reply_required": False,
|
||||
"exact_match_only": False,
|
||||
"window_scope": "conversation",
|
||||
"visibility_mode": "status_in_source",
|
||||
},
|
||||
)
|
||||
if not profile.enabled:
|
||||
profile.enabled = True
|
||||
profile.save(update_fields=["enabled", "updated_at"])
|
||||
if str(profile.trigger_token or "").strip() != ".codex":
|
||||
profile.trigger_token = ".codex"
|
||||
profile.save(update_fields=["trigger_token", "updated_at"])
|
||||
return profile
|
||||
|
||||
|
||||
def _ensure_profile_for_slug(user_id: int, slug: str) -> CommandProfile | None:
|
||||
if slug == "bp":
|
||||
return _ensure_bp_profile(user_id)
|
||||
if slug == "codex":
|
||||
return _ensure_codex_profile(user_id)
|
||||
return None
|
||||
|
||||
|
||||
def _detected_bootstrap_slugs(message_text: str) -> list[str]:
|
||||
slugs: list[str] = []
|
||||
if bp_trigger_matches(message_text, ".bp", False):
|
||||
slugs.append("bp")
|
||||
if codex_trigger_matches(message_text, ".codex", False):
|
||||
slugs.append("codex")
|
||||
return slugs
|
||||
|
||||
|
||||
def _auto_setup_profile_bindings_for_first_command(
|
||||
ctx: CommandContext,
|
||||
trigger_message: Message,
|
||||
) -> None:
|
||||
author = str(getattr(trigger_message, "custom_author", "") or "").strip().upper()
|
||||
if author != "USER":
|
||||
return
|
||||
slugs = _detected_bootstrap_slugs(ctx.message_text)
|
||||
if not slugs:
|
||||
return
|
||||
service, identifier = _effective_bootstrap_scope(ctx, trigger_message)
|
||||
service = str(service or "").strip().lower()
|
||||
canonical = _canonical_channel_identifier(service, identifier)
|
||||
variants = _channel_variants(service, canonical)
|
||||
if not service or not variants:
|
||||
return
|
||||
for slug in slugs:
|
||||
profile = _ensure_profile_for_slug(ctx.user_id, slug)
|
||||
if profile is None:
|
||||
continue
|
||||
already_enabled = CommandChannelBinding.objects.filter(
|
||||
profile=profile,
|
||||
enabled=True,
|
||||
direction="ingress",
|
||||
service=service,
|
||||
channel_identifier__in=variants,
|
||||
).exists()
|
||||
if already_enabled:
|
||||
continue
|
||||
for direction in ("ingress", "egress"):
|
||||
binding, _ = CommandChannelBinding.objects.get_or_create(
|
||||
profile=profile,
|
||||
direction=direction,
|
||||
service=service,
|
||||
channel_identifier=canonical,
|
||||
defaults={"enabled": True},
|
||||
)
|
||||
if not binding.enabled:
|
||||
binding.enabled = True
|
||||
binding.save(update_fields=["enabled", "updated_at"])
|
||||
alternate_variants = [value for value in variants if value != canonical]
|
||||
if alternate_variants:
|
||||
CommandChannelBinding.objects.filter(
|
||||
profile=profile,
|
||||
direction=direction,
|
||||
service=service,
|
||||
channel_identifier__in=alternate_variants,
|
||||
).update(enabled=False)
|
||||
|
||||
|
||||
def ensure_handlers_registered():
|
||||
global _REGISTERED
|
||||
if _REGISTERED:
|
||||
return
|
||||
register(BPCommandHandler())
|
||||
register(CodexCommandHandler())
|
||||
_REGISTERED = True
|
||||
|
||||
|
||||
async def _eligible_profiles(ctx: CommandContext) -> list[CommandProfile]:
|
||||
def _load():
|
||||
trigger = (
|
||||
Message.objects.select_related("session", "session__identifier")
|
||||
.filter(id=ctx.message_id, user_id=ctx.user_id)
|
||||
.first()
|
||||
)
|
||||
direct_variants = _channel_variants(ctx.service, ctx.channel_identifier)
|
||||
source_channel = str(getattr(trigger, "source_chat_id", "") or "").strip()
|
||||
for expanded in _channel_variants(ctx.service, source_channel):
|
||||
if expanded and expanded not in direct_variants:
|
||||
direct_variants.append(expanded)
|
||||
if not direct_variants:
|
||||
return []
|
||||
direct = list(
|
||||
@@ -65,15 +228,13 @@ async def _eligible_profiles(ctx: CommandContext) -> list[CommandProfile]:
|
||||
# underlying conversation is mapped to a platform identifier.
|
||||
if str(ctx.service or "").strip().lower() != "web":
|
||||
return []
|
||||
trigger = (
|
||||
Message.objects.select_related("session", "session__identifier")
|
||||
.filter(id=ctx.message_id, user_id=ctx.user_id)
|
||||
.first()
|
||||
)
|
||||
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()
|
||||
fallback_variants = _channel_variants(fallback_service, fallback_identifier)
|
||||
for expanded in _channel_variants(fallback_service, source_channel):
|
||||
if expanded and expanded not in fallback_variants:
|
||||
fallback_variants.append(expanded)
|
||||
if not fallback_service or not fallback_variants:
|
||||
return []
|
||||
return list(
|
||||
@@ -91,12 +252,18 @@ async def _eligible_profiles(ctx: CommandContext) -> list[CommandProfile]:
|
||||
|
||||
|
||||
def _matches_trigger(profile: CommandProfile, text: str) -> bool:
|
||||
if profile.slug == "bp" and bool(getattr(settings, "BP_SUBCOMMANDS_V1", True)):
|
||||
if profile.slug == "bp" and bp_subcommands_enabled():
|
||||
return bp_trigger_matches(
|
||||
message_text=text,
|
||||
trigger_token=profile.trigger_token,
|
||||
exact_match_only=profile.exact_match_only,
|
||||
)
|
||||
if profile.slug == "codex":
|
||||
return codex_trigger_matches(
|
||||
message_text=text,
|
||||
trigger_token=profile.trigger_token,
|
||||
exact_match_only=profile.exact_match_only,
|
||||
)
|
||||
body = str(text or "").strip()
|
||||
trigger = str(profile.trigger_token or "").strip()
|
||||
if not trigger:
|
||||
@@ -115,6 +282,10 @@ async def process_inbound_message(ctx: CommandContext) -> list[CommandResult]:
|
||||
return []
|
||||
if is_mirrored_origin(trigger_message.message_meta):
|
||||
return []
|
||||
await sync_to_async(_auto_setup_profile_bindings_for_first_command)(
|
||||
ctx,
|
||||
trigger_message,
|
||||
)
|
||||
|
||||
profiles = await _eligible_profiles(ctx)
|
||||
results: list[CommandResult] = []
|
||||
@@ -124,7 +295,7 @@ async def process_inbound_message(ctx: CommandContext) -> list[CommandResult]:
|
||||
if profile.reply_required and trigger_message.reply_to_id is None:
|
||||
if (
|
||||
profile.slug == "bp"
|
||||
and bool(getattr(settings, "BP_SUBCOMMANDS_V1", True))
|
||||
and bp_subcommands_enabled()
|
||||
and bp_reply_is_optional_for_trigger(ctx.message_text)
|
||||
):
|
||||
pass
|
||||
|
||||
@@ -23,8 +23,15 @@ from core.models import (
|
||||
Message,
|
||||
)
|
||||
|
||||
_BP_SET_RE = re.compile(r"^\s*#bp\s+set#(?P<rest>.*)$", re.IGNORECASE | re.DOTALL)
|
||||
_BP_SET_RANGE_RE = re.compile(r"^\s*#bp\s+set\s+range#(?:.*)$", re.IGNORECASE | re.DOTALL)
|
||||
_BP_ROOT_RE = re.compile(r"^\s*(?:\.bp\b|#bp#?)\s*$", re.IGNORECASE)
|
||||
_BP_SET_RE = re.compile(
|
||||
r"^\s*(?:\.bp\s+set\b|#bp\s+set#?)(?P<rest>.*)$",
|
||||
re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
_BP_SET_RANGE_RE = re.compile(
|
||||
r"^\s*(?:\.bp\s+set\s+range\b|#bp\s+set\s+range#?)(?:.*)$",
|
||||
re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
|
||||
|
||||
class BPParsedCommand(dict):
|
||||
@@ -49,17 +56,26 @@ def parse_bp_subcommand(text: str) -> BPParsedCommand:
|
||||
return BPParsedCommand(command=None, remainder_text="")
|
||||
|
||||
|
||||
def bp_subcommands_enabled() -> bool:
|
||||
raw = getattr(settings, "BP_SUBCOMMANDS_V1", True)
|
||||
if raw is None:
|
||||
return True
|
||||
return bool(raw)
|
||||
|
||||
|
||||
def bp_trigger_matches(message_text: str, trigger_token: str, exact_match_only: bool) -> bool:
|
||||
body = str(message_text or "").strip()
|
||||
trigger = str(trigger_token or "").strip()
|
||||
parsed = parse_bp_subcommand(body)
|
||||
if parsed.command and bool(getattr(settings, "BP_SUBCOMMANDS_V1", True)):
|
||||
if parsed.command and bp_subcommands_enabled():
|
||||
return True
|
||||
if _BP_ROOT_RE.match(body):
|
||||
return True
|
||||
if not trigger:
|
||||
return False
|
||||
if exact_match_only:
|
||||
return body == trigger
|
||||
return trigger in body
|
||||
return body.lower() == trigger.lower()
|
||||
return trigger.lower() in body.lower()
|
||||
|
||||
|
||||
def bp_reply_is_optional_for_trigger(message_text: str) -> bool:
|
||||
@@ -119,7 +135,8 @@ class BPCommandHandler(CommandHandler):
|
||||
"generation_mode": str(policy.generation_mode or "verbatim"),
|
||||
"send_plan_to_egress": bool(policy.send_plan_to_egress)
|
||||
and ("post_result" in action_types),
|
||||
"send_status_to_source": bool(policy.send_status_to_source),
|
||||
"send_status_to_source": bool(policy.send_status_to_source)
|
||||
or str(profile.visibility_mode or "") == "status_in_source",
|
||||
"send_status_to_egress": bool(policy.send_status_to_egress),
|
||||
"store_document": bool(getattr(policy, "store_document", True)),
|
||||
}
|
||||
@@ -614,7 +631,7 @@ class BPCommandHandler(CommandHandler):
|
||||
return CommandResult(ok=False, status="skipped", error=run.error)
|
||||
|
||||
parsed = parse_bp_subcommand(ctx.message_text)
|
||||
if parsed.command and bool(getattr(settings, "BP_SUBCOMMANDS_V1", True)):
|
||||
if parsed.command and bp_subcommands_enabled():
|
||||
return await self._execute_set_or_range(
|
||||
trigger=trigger,
|
||||
run=run,
|
||||
|
||||
498
core/commands/handlers/codex.py
Normal file
498
core/commands/handlers/codex.py
Normal file
@@ -0,0 +1,498 @@
|
||||
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<body>.*)$",
|
||||
re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
_CODEX_PLAN_RE = re.compile(
|
||||
r"^\s*(?:\.codex\s+plan\b|#codex\s+plan#?)(?P<body>.*)$",
|
||||
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+(?P<action>approve|deny)\s+(?P<approval_key>[A-Za-z0-9._:-]+)#?\s*$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_PROJECT_TOKEN_RE = re.compile(r"\[\s*project\s*:\s*([^\]]+)\]", re.IGNORECASE)
|
||||
_REFERENCE_RE = re.compile(r"(?<!\w)#([A-Za-z0-9_-]+)\b")
|
||||
|
||||
|
||||
class CodexParsedCommand(dict):
|
||||
@property
|
||||
def command(self) -> 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,
|
||||
)
|
||||
@@ -8,19 +8,19 @@ BP_VARIANT_KEYS = ("bp", "bp_set", "bp_set_range")
|
||||
BP_VARIANT_META = {
|
||||
"bp": {
|
||||
"name": "bp",
|
||||
"trigger_token": "#bp#",
|
||||
"trigger_token": ".bp",
|
||||
"template_supported": True,
|
||||
"position": 0,
|
||||
},
|
||||
"bp_set": {
|
||||
"name": "bp set",
|
||||
"trigger_token": "#bp set#",
|
||||
"trigger_token": ".bp set",
|
||||
"template_supported": False,
|
||||
"position": 1,
|
||||
},
|
||||
"bp_set_range": {
|
||||
"name": "bp set range",
|
||||
"trigger_token": "#bp set range#",
|
||||
"trigger_token": ".bp set range",
|
||||
"template_supported": False,
|
||||
"position": 2,
|
||||
},
|
||||
@@ -63,6 +63,9 @@ def ensure_variant_policies_for_profile(
|
||||
result: dict[str, CommandVariantPolicy] = {}
|
||||
|
||||
if str(profile.slug or "").strip() == "bp":
|
||||
# Keep source-chat status visible for BP to avoid "silent success" confusion.
|
||||
if str(profile.visibility_mode or "").strip() == "status_in_source":
|
||||
CommandVariantPolicy.objects.filter(profile=profile).update(send_status_to_source=True)
|
||||
for key in BP_VARIANT_KEYS:
|
||||
meta = BP_VARIANT_META.get(key, {})
|
||||
defaults = _bp_defaults(profile, key, post_result_enabled)
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from asgiref.sync import async_to_sync
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from core.models import ExternalSyncEvent, TaskProviderConfig
|
||||
from core.clients.transport import send_message_raw
|
||||
from core.models import CodexPermissionRequest, CodexRun, ExternalSyncEvent, TaskProviderConfig
|
||||
from core.tasks.providers import get_provider
|
||||
from core.util import logs
|
||||
|
||||
@@ -58,11 +61,36 @@ class Command(BaseCommand):
|
||||
event.status = "failed"
|
||||
event.error = "provider_disabled_or_missing"
|
||||
event.save(update_fields=["status", "error", "updated_at"])
|
||||
provider_payload = dict((event.payload or {}).get("provider_payload") or {})
|
||||
run_id = str(provider_payload.get("codex_run_id") or "").strip()
|
||||
if run_id:
|
||||
CodexRun.objects.filter(id=run_id, user=event.user).update(
|
||||
status="failed",
|
||||
error="provider_disabled_or_missing",
|
||||
)
|
||||
return
|
||||
|
||||
payload = dict(event.payload or {})
|
||||
action = str(payload.get("action") or "append_update").strip().lower()
|
||||
provider_payload = dict(payload.get("provider_payload") or payload)
|
||||
run_id = str(provider_payload.get("codex_run_id") or payload.get("codex_run_id") or "").strip()
|
||||
codex_run = None
|
||||
if run_id:
|
||||
codex_run = CodexRun.objects.filter(id=run_id, user=event.user).first()
|
||||
if codex_run is None and event.task_id:
|
||||
codex_run = (
|
||||
CodexRun.objects.filter(
|
||||
user=event.user,
|
||||
task_id=event.task_id,
|
||||
status__in=["queued", "running", "approved_waiting_resume"],
|
||||
)
|
||||
.order_by("-updated_at")
|
||||
.first()
|
||||
)
|
||||
if codex_run is not None:
|
||||
codex_run.status = "running"
|
||||
codex_run.error = ""
|
||||
codex_run.save(update_fields=["status", "error", "updated_at"])
|
||||
|
||||
if action == "create":
|
||||
result = provider.create_task(dict(cfg.settings or {}), provider_payload)
|
||||
@@ -73,14 +101,106 @@ class Command(BaseCommand):
|
||||
else:
|
||||
result = provider.append_update(dict(cfg.settings or {}), provider_payload)
|
||||
|
||||
result_payload = dict(result.payload or {})
|
||||
requires_approval = bool(result_payload.get("requires_approval"))
|
||||
if requires_approval:
|
||||
approval_key = str(result_payload.get("approval_key") or uuid.uuid4().hex[:12]).strip()
|
||||
permission_request = dict(result_payload.get("permission_request") or {})
|
||||
summary = str(result_payload.get("summary") or permission_request.get("summary") or "").strip()
|
||||
requested_permissions = permission_request.get("requested_permissions")
|
||||
if not isinstance(requested_permissions, (list, dict)):
|
||||
requested_permissions = permission_request or {}
|
||||
resume_payload = result_payload.get("resume_payload")
|
||||
if not isinstance(resume_payload, dict):
|
||||
resume_payload = {}
|
||||
event.status = "waiting_approval"
|
||||
event.error = ""
|
||||
event.payload = dict(payload, worker_processed=True, result=result_payload)
|
||||
event.save(update_fields=["status", "error", "payload", "updated_at"])
|
||||
if codex_run is not None:
|
||||
codex_run.status = "waiting_approval"
|
||||
codex_run.result_payload = dict(result_payload)
|
||||
codex_run.error = ""
|
||||
codex_run.save(update_fields=["status", "result_payload", "error", "updated_at"])
|
||||
CodexPermissionRequest.objects.update_or_create(
|
||||
approval_key=approval_key,
|
||||
defaults={
|
||||
"user": event.user,
|
||||
"codex_run": codex_run if codex_run is not None else CodexRun.objects.create(
|
||||
user=event.user,
|
||||
task=event.task,
|
||||
derived_task_event=event.task_event,
|
||||
source_service=str(provider_payload.get("source_service") or ""),
|
||||
source_channel=str(provider_payload.get("source_channel") or ""),
|
||||
external_chat_id=str(provider_payload.get("external_chat_id") or ""),
|
||||
status="waiting_approval",
|
||||
request_payload=dict(payload or {}),
|
||||
result_payload=dict(result_payload),
|
||||
error="",
|
||||
),
|
||||
"external_sync_event": event,
|
||||
"summary": summary,
|
||||
"requested_permissions": requested_permissions if isinstance(requested_permissions, dict) else {
|
||||
"items": list(requested_permissions or [])
|
||||
},
|
||||
"resume_payload": dict(resume_payload or {}),
|
||||
"status": "pending",
|
||||
"resolved_at": None,
|
||||
"resolved_by_identifier": "",
|
||||
"resolution_note": "",
|
||||
},
|
||||
)
|
||||
approver_service = str((cfg.settings or {}).get("approver_service") or "").strip().lower()
|
||||
approver_identifier = str((cfg.settings or {}).get("approver_identifier") or "").strip()
|
||||
requested_text = result_payload.get("permission_request") or result_payload.get("requested_permissions") or {}
|
||||
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"
|
||||
f"summary={summary or 'Codex run requires approval'}\\n"
|
||||
f"requested={requested_text}\\n"
|
||||
f"use: .codex approve {approval_key} or .codex deny {approval_key}"
|
||||
),
|
||||
attachments=[],
|
||||
metadata={"origin_tag": f"codex-approval:{approval_key}"},
|
||||
)
|
||||
except Exception:
|
||||
log.exception("failed to notify approver channel for approval_key=%s", approval_key)
|
||||
else:
|
||||
source_service = str(provider_payload.get("source_service") or "").strip().lower()
|
||||
source_channel = str(provider_payload.get("source_channel") or "").strip()
|
||||
if source_service and source_channel:
|
||||
try:
|
||||
async_to_sync(send_message_raw)(
|
||||
source_service,
|
||||
source_channel,
|
||||
text=(
|
||||
"[codex approval] approval is pending but no approver channel is configured. "
|
||||
"Set approver_service and approver_identifier in Codex settings."
|
||||
),
|
||||
attachments=[],
|
||||
metadata={"origin_tag": "codex-approval-missing-target"},
|
||||
)
|
||||
except Exception:
|
||||
log.exception("failed to notify source channel for missing approver target")
|
||||
return
|
||||
|
||||
event.status = "ok" if result.ok else "failed"
|
||||
event.error = str(result.error or "")
|
||||
event.payload = dict(
|
||||
payload,
|
||||
worker_processed=True,
|
||||
result=dict(result.payload or {}),
|
||||
result=result_payload,
|
||||
)
|
||||
event.save(update_fields=["status", "error", "payload", "updated_at"])
|
||||
if codex_run is not None:
|
||||
codex_run.status = "ok" if result.ok else "failed"
|
||||
codex_run.error = str(result.error or "")
|
||||
codex_run.result_payload = result_payload
|
||||
codex_run.save(update_fields=["status", "error", "result_payload", "updated_at"])
|
||||
|
||||
if result.ok and result.external_key and event.task_id and not str(event.task.external_key or "").strip():
|
||||
event.task.external_key = str(result.external_key)
|
||||
|
||||
187
core/migrations/0034_codexrun_codexpermissionrequest_and_more.py
Normal file
187
core/migrations/0034_codexrun_codexpermissionrequest_and_more.py
Normal file
@@ -0,0 +1,187 @@
|
||||
import uuid
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("core", "0033_contactavailability_and_externalchatlink"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="externalsyncevent",
|
||||
name="status",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("pending", "Pending"),
|
||||
("ok", "OK"),
|
||||
("failed", "Failed"),
|
||||
("retrying", "Retrying"),
|
||||
("waiting_approval", "Waiting Approval"),
|
||||
],
|
||||
default="pending",
|
||||
max_length=32,
|
||||
),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="CodexRun",
|
||||
fields=[
|
||||
("id", models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
("source_service", models.CharField(blank=True, default="", max_length=255)),
|
||||
("source_channel", models.CharField(blank=True, default="", max_length=255)),
|
||||
("external_chat_id", models.CharField(blank=True, default="", max_length=255)),
|
||||
(
|
||||
"status",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("queued", "Queued"),
|
||||
("running", "Running"),
|
||||
("waiting_approval", "Waiting Approval"),
|
||||
("approved_waiting_resume", "Approved Waiting Resume"),
|
||||
("denied", "Denied"),
|
||||
("ok", "OK"),
|
||||
("failed", "Failed"),
|
||||
("cancelled", "Cancelled"),
|
||||
],
|
||||
default="queued",
|
||||
max_length=32,
|
||||
),
|
||||
),
|
||||
("request_payload", models.JSONField(blank=True, default=dict)),
|
||||
("result_payload", models.JSONField(blank=True, default=dict)),
|
||||
("error", models.TextField(blank=True, default="")),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
(
|
||||
"derived_task_event",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="codex_runs",
|
||||
to="core.derivedtaskevent",
|
||||
),
|
||||
),
|
||||
(
|
||||
"epic",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="codex_runs",
|
||||
to="core.taskepic",
|
||||
),
|
||||
),
|
||||
(
|
||||
"project",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="codex_runs",
|
||||
to="core.taskproject",
|
||||
),
|
||||
),
|
||||
(
|
||||
"source_message",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="codex_runs",
|
||||
to="core.message",
|
||||
),
|
||||
),
|
||||
(
|
||||
"task",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="codex_runs",
|
||||
to="core.derivedtask",
|
||||
),
|
||||
),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="codex_runs",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"indexes": [
|
||||
models.Index(fields=["user", "status", "updated_at"], name="core_codexr_user_id_dddd7f_idx"),
|
||||
models.Index(
|
||||
fields=["user", "source_service", "source_channel", "created_at"],
|
||||
name="core_codexr_user_id_a70a53_idx",
|
||||
),
|
||||
],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="CodexPermissionRequest",
|
||||
fields=[
|
||||
("id", models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
("approval_key", models.CharField(max_length=255, unique=True)),
|
||||
("summary", models.TextField(blank=True, default="")),
|
||||
("requested_permissions", models.JSONField(blank=True, default=dict)),
|
||||
("resume_payload", models.JSONField(blank=True, default=dict)),
|
||||
(
|
||||
"status",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("pending", "Pending"),
|
||||
("approved", "Approved"),
|
||||
("denied", "Denied"),
|
||||
("expired", "Expired"),
|
||||
],
|
||||
default="pending",
|
||||
max_length=16,
|
||||
),
|
||||
),
|
||||
("requested_at", models.DateTimeField(auto_now_add=True)),
|
||||
("resolved_at", models.DateTimeField(blank=True, null=True)),
|
||||
("resolved_by_identifier", models.CharField(blank=True, default="", max_length=255)),
|
||||
("resolution_note", models.TextField(blank=True, default="")),
|
||||
(
|
||||
"codex_run",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="permission_requests",
|
||||
to="core.codexrun",
|
||||
),
|
||||
),
|
||||
(
|
||||
"external_sync_event",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="codex_permission_requests",
|
||||
to="core.externalsyncevent",
|
||||
),
|
||||
),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="codex_permission_requests",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"indexes": [
|
||||
models.Index(fields=["user", "status", "requested_at"], name="core_codexp_user_id_ba71e9_idx"),
|
||||
models.Index(fields=["approval_key"], name="core_codexp_approva_83035d_idx"),
|
||||
],
|
||||
},
|
||||
),
|
||||
]
|
||||
106
core/models.py
106
core/models.py
@@ -2176,6 +2176,7 @@ class ExternalSyncEvent(models.Model):
|
||||
("ok", "OK"),
|
||||
("failed", "Failed"),
|
||||
("retrying", "Retrying"),
|
||||
("waiting_approval", "Waiting Approval"),
|
||||
)
|
||||
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
@@ -2227,6 +2228,111 @@ class TaskProviderConfig(models.Model):
|
||||
]
|
||||
|
||||
|
||||
class CodexRun(models.Model):
|
||||
STATUS_CHOICES = (
|
||||
("queued", "Queued"),
|
||||
("running", "Running"),
|
||||
("waiting_approval", "Waiting Approval"),
|
||||
("approved_waiting_resume", "Approved Waiting Resume"),
|
||||
("denied", "Denied"),
|
||||
("ok", "OK"),
|
||||
("failed", "Failed"),
|
||||
("cancelled", "Cancelled"),
|
||||
)
|
||||
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="codex_runs")
|
||||
task = models.ForeignKey(
|
||||
DerivedTask,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="codex_runs",
|
||||
)
|
||||
derived_task_event = models.ForeignKey(
|
||||
DerivedTaskEvent,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="codex_runs",
|
||||
)
|
||||
source_message = models.ForeignKey(
|
||||
Message,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="codex_runs",
|
||||
)
|
||||
project = models.ForeignKey(
|
||||
TaskProject,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="codex_runs",
|
||||
)
|
||||
epic = models.ForeignKey(
|
||||
TaskEpic,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="codex_runs",
|
||||
)
|
||||
source_service = models.CharField(max_length=255, blank=True, default="")
|
||||
source_channel = models.CharField(max_length=255, blank=True, default="")
|
||||
external_chat_id = models.CharField(max_length=255, blank=True, default="")
|
||||
status = models.CharField(max_length=32, choices=STATUS_CHOICES, default="queued")
|
||||
request_payload = models.JSONField(default=dict, blank=True)
|
||||
result_payload = models.JSONField(default=dict, blank=True)
|
||||
error = models.TextField(blank=True, default="")
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
indexes = [
|
||||
models.Index(fields=["user", "status", "updated_at"]),
|
||||
models.Index(fields=["user", "source_service", "source_channel", "created_at"]),
|
||||
]
|
||||
|
||||
|
||||
class CodexPermissionRequest(models.Model):
|
||||
STATUS_CHOICES = (
|
||||
("pending", "Pending"),
|
||||
("approved", "Approved"),
|
||||
("denied", "Denied"),
|
||||
("expired", "Expired"),
|
||||
)
|
||||
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="codex_permission_requests")
|
||||
codex_run = models.ForeignKey(
|
||||
CodexRun,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="permission_requests",
|
||||
)
|
||||
external_sync_event = models.ForeignKey(
|
||||
ExternalSyncEvent,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="codex_permission_requests",
|
||||
)
|
||||
approval_key = models.CharField(max_length=255, unique=True)
|
||||
summary = models.TextField(blank=True, default="")
|
||||
requested_permissions = models.JSONField(default=dict, blank=True)
|
||||
resume_payload = models.JSONField(default=dict, blank=True)
|
||||
status = models.CharField(max_length=16, choices=STATUS_CHOICES, default="pending")
|
||||
requested_at = models.DateTimeField(auto_now_add=True)
|
||||
resolved_at = models.DateTimeField(null=True, blank=True)
|
||||
resolved_by_identifier = models.CharField(max_length=255, blank=True, default="")
|
||||
resolution_note = models.TextField(blank=True, default="")
|
||||
|
||||
class Meta:
|
||||
indexes = [
|
||||
models.Index(fields=["user", "status", "requested_at"]),
|
||||
models.Index(fields=["approval_key"]),
|
||||
]
|
||||
|
||||
|
||||
class ContactAvailabilitySettings(models.Model):
|
||||
user = models.OneToOneField(
|
||||
User,
|
||||
|
||||
71
core/tasks/codex_support.py
Normal file
71
core/tasks/codex_support.py
Normal file
@@ -0,0 +1,71 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from django.db.models import Q
|
||||
|
||||
from core.models import ExternalChatLink, PersonIdentifier
|
||||
|
||||
|
||||
def channel_variants(service: str, channel: str) -> list[str]:
|
||||
value = str(channel or "").strip()
|
||||
if not value:
|
||||
return []
|
||||
variants = [value]
|
||||
service_key = str(service or "").strip().lower()
|
||||
if service_key == "whatsapp":
|
||||
bare = value.split("@", 1)[0].strip()
|
||||
if bare and bare not in variants:
|
||||
variants.append(bare)
|
||||
direct = f"{bare}@s.whatsapp.net" if bare else ""
|
||||
if direct and direct not in variants:
|
||||
variants.append(direct)
|
||||
group = f"{bare}@g.us" if bare else ""
|
||||
if group and group not in variants:
|
||||
variants.append(group)
|
||||
if service_key == "signal":
|
||||
digits = re.sub(r"[^0-9]", "", value)
|
||||
if digits and digits not in variants:
|
||||
variants.append(digits)
|
||||
if digits:
|
||||
plus = f"+{digits}"
|
||||
if plus not in variants:
|
||||
variants.append(plus)
|
||||
return variants
|
||||
|
||||
|
||||
def resolve_external_chat_id(*, user, provider: str, service: str, channel: str) -> str:
|
||||
variants = channel_variants(service, channel)
|
||||
if not variants:
|
||||
return ""
|
||||
person_identifier = (
|
||||
PersonIdentifier.objects.filter(
|
||||
user=user,
|
||||
service=service,
|
||||
identifier__in=variants,
|
||||
)
|
||||
.select_related("person")
|
||||
.order_by("-id")
|
||||
.first()
|
||||
)
|
||||
if person_identifier is None:
|
||||
return ""
|
||||
link = (
|
||||
ExternalChatLink.objects.filter(
|
||||
user=user,
|
||||
provider=provider,
|
||||
enabled=True,
|
||||
)
|
||||
.filter(Q(person_identifier=person_identifier) | Q(person=person_identifier.person))
|
||||
.order_by("-updated_at", "-id")
|
||||
.first()
|
||||
)
|
||||
return str(getattr(link, "external_chat_id", "") or "").strip()
|
||||
|
||||
|
||||
def compact_json_snippet(payload: Any, limit: int = 800) -> str:
|
||||
text = str(payload or "").strip()
|
||||
if len(text) <= limit:
|
||||
return text
|
||||
return text[:limit].rstrip() + "..."
|
||||
@@ -4,7 +4,6 @@ import re
|
||||
|
||||
from asgiref.sync import sync_to_async
|
||||
from django.conf import settings
|
||||
from django.db.models import Q
|
||||
|
||||
from core.clients.transport import send_message_raw
|
||||
from core.messaging import ai as ai_runner
|
||||
@@ -12,21 +11,36 @@ from core.models import (
|
||||
AI,
|
||||
Chat,
|
||||
ChatTaskSource,
|
||||
CodexRun,
|
||||
DerivedTask,
|
||||
DerivedTaskEvent,
|
||||
ExternalSyncEvent,
|
||||
ExternalChatLink,
|
||||
Message,
|
||||
PersonIdentifier,
|
||||
TaskCompletionPattern,
|
||||
TaskEpic,
|
||||
TaskProviderConfig,
|
||||
)
|
||||
from core.tasks.providers import get_provider
|
||||
from core.tasks.codex_support import resolve_external_chat_id
|
||||
|
||||
_TASK_HINT_RE = re.compile(r"\b(todo|task|action|need to|please)\b", re.IGNORECASE)
|
||||
_COMPLETION_RE = re.compile(r"\b(done|completed|fixed)\s*#([A-Za-z0-9_-]+)\b", re.IGNORECASE)
|
||||
_BALANCED_HINT_RE = re.compile(r"\b(todo|task|action item|action)\b", re.IGNORECASE)
|
||||
_BROAD_HINT_RE = re.compile(r"\b(todo|task|action|need to|please|reminder)\b", re.IGNORECASE)
|
||||
_PREFIX_HEAD_TRIM = " \t\r\n`'\"([{<*#-–—_>.,:;!/?\\|"
|
||||
_LIST_TASKS_RE = re.compile(
|
||||
r"^\s*(?:\.l(?:\s+list(?:\s+tasks?)?)?|\.list(?:\s+tasks?)?)\s*$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_UNDO_TASK_RE = re.compile(
|
||||
r"^\s*\.undo(?:\s+(?:#)?(?P<reference>[A-Za-z0-9_-]+))?\s*$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_EPIC_CREATE_RE = re.compile(
|
||||
r"^\s*(?:\.epic\b|epic)\s*[:\-]?\s*(?P<name>.+?)\s*$",
|
||||
re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
_EPIC_TOKEN_RE = re.compile(r"\[\s*epic\s*:\s*([^\]]+?)\s*\]", re.IGNORECASE)
|
||||
|
||||
|
||||
def _channel_variants(service: str, channel: str) -> list[str]:
|
||||
@@ -57,27 +71,44 @@ def _channel_variants(service: str, channel: str) -> list[str]:
|
||||
|
||||
|
||||
async def _resolve_source_mappings(message: Message) -> list[ChatTaskSource]:
|
||||
variants = _channel_variants(message.source_service or "", message.source_chat_id or "")
|
||||
if str(message.source_service or "").strip().lower() == "signal":
|
||||
signal_value = str(message.source_chat_id or "").strip()
|
||||
if signal_value:
|
||||
companions = await sync_to_async(list)(
|
||||
lookup_service = str(message.source_service or "").strip().lower()
|
||||
variants = _channel_variants(lookup_service, message.source_chat_id or "")
|
||||
session_identifier = getattr(getattr(message, "session", None), "identifier", None)
|
||||
canonical_service = str(getattr(session_identifier, "service", "") or "").strip().lower()
|
||||
canonical_identifier = str(getattr(session_identifier, "identifier", "") or "").strip()
|
||||
if lookup_service == "web" and canonical_service and canonical_service != "web":
|
||||
lookup_service = canonical_service
|
||||
variants = _channel_variants(lookup_service, message.source_chat_id or "")
|
||||
for expanded in _channel_variants(lookup_service, canonical_identifier):
|
||||
if expanded and expanded not in variants:
|
||||
variants.append(expanded)
|
||||
elif canonical_service and canonical_identifier and canonical_service == lookup_service:
|
||||
for expanded in _channel_variants(canonical_service, canonical_identifier):
|
||||
if expanded and expanded not in variants:
|
||||
variants.append(expanded)
|
||||
if lookup_service == "signal":
|
||||
companions: list[str] = []
|
||||
for value in list(variants):
|
||||
signal_value = str(value or "").strip()
|
||||
if not signal_value:
|
||||
continue
|
||||
companions += await sync_to_async(list)(
|
||||
Chat.objects.filter(source_uuid=signal_value).values_list("source_number", flat=True)
|
||||
)
|
||||
companions += await sync_to_async(list)(
|
||||
Chat.objects.filter(source_number=signal_value).values_list("source_uuid", flat=True)
|
||||
)
|
||||
for candidate in companions:
|
||||
for expanded in _channel_variants("signal", str(candidate or "").strip()):
|
||||
if expanded and expanded not in variants:
|
||||
variants.append(expanded)
|
||||
for candidate in companions:
|
||||
for expanded in _channel_variants("signal", str(candidate or "").strip()):
|
||||
if expanded and expanded not in variants:
|
||||
variants.append(expanded)
|
||||
if not variants:
|
||||
return []
|
||||
return await sync_to_async(list)(
|
||||
ChatTaskSource.objects.filter(
|
||||
user=message.user,
|
||||
enabled=True,
|
||||
service=message.source_service,
|
||||
service=lookup_service,
|
||||
channel_identifier__in=variants,
|
||||
).select_related("project", "epic")
|
||||
)
|
||||
@@ -107,6 +138,58 @@ def _parse_prefixes(raw) -> list[str]:
|
||||
return rows or ["task:", "todo:", "action:"]
|
||||
|
||||
|
||||
def _prefix_roots(prefixes: list[str]) -> list[str]:
|
||||
roots: list[str] = []
|
||||
for value in prefixes:
|
||||
token = str(value or "").strip().lower()
|
||||
if not token:
|
||||
continue
|
||||
token = token.lstrip(_PREFIX_HEAD_TRIM)
|
||||
match = re.match(r"([a-z0-9]+)", token)
|
||||
if not match:
|
||||
continue
|
||||
root = str(match.group(1) or "").strip()
|
||||
if root and root not in roots:
|
||||
roots.append(root)
|
||||
return roots
|
||||
|
||||
|
||||
def _has_task_prefix(text: str, prefixes: list[str]) -> bool:
|
||||
body = str(text or "").strip().lower()
|
||||
if not body:
|
||||
return False
|
||||
if any(body.startswith(prefix) for prefix in prefixes):
|
||||
return True
|
||||
trimmed = body.lstrip(_PREFIX_HEAD_TRIM)
|
||||
roots = _prefix_roots(prefixes)
|
||||
if not trimmed or not roots:
|
||||
return False
|
||||
for root in roots:
|
||||
if re.match(rf"^{re.escape(root)}\b(?:\s*[:\-–—#>.,;!]*\s*|\s+)", trimmed):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _strip_task_prefix(text: str, prefixes: list[str]) -> str:
|
||||
body = str(text or "").strip()
|
||||
if not body:
|
||||
return ""
|
||||
trimmed = body.lstrip(_PREFIX_HEAD_TRIM)
|
||||
roots = _prefix_roots(prefixes)
|
||||
if not trimmed or not roots:
|
||||
return body
|
||||
for root in roots:
|
||||
match = re.match(
|
||||
rf"^{re.escape(root)}\b(?:\s*[:\-–—#>.,;!]*\s*|\s+)(.+)$",
|
||||
trimmed,
|
||||
flags=re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
if match:
|
||||
cleaned = str(match.group(1) or "").strip()
|
||||
return cleaned or body
|
||||
return body
|
||||
|
||||
|
||||
def _normalize_flags(raw: dict | None) -> dict:
|
||||
row = dict(raw or {})
|
||||
return {
|
||||
@@ -157,7 +240,7 @@ def _is_task_candidate(text: str, flags: dict) -> bool:
|
||||
return False
|
||||
body_lower = body.lower()
|
||||
prefixes = list(flags.get("allowed_prefixes") or [])
|
||||
has_prefix = any(body_lower.startswith(prefix) for prefix in prefixes)
|
||||
has_prefix = _has_task_prefix(body_lower, prefixes)
|
||||
if bool(flags.get("require_prefix")) and not has_prefix:
|
||||
return False
|
||||
mode = str(flags.get("match_mode") or "balanced").strip().lower()
|
||||
@@ -207,10 +290,13 @@ async def _derive_title(message: Message) -> str:
|
||||
|
||||
|
||||
async def _derive_title_with_flags(message: Message, flags: dict) -> str:
|
||||
prefixes = list(flags.get("allowed_prefixes") or [])
|
||||
if not bool(flags.get("ai_title_enabled", True)):
|
||||
text = str(message.text or "").strip()
|
||||
text = _strip_task_prefix(str(message.text or "").strip(), prefixes)
|
||||
return (text or "Untitled task")[:255]
|
||||
return await _derive_title(message)
|
||||
title = await _derive_title(message)
|
||||
cleaned = _strip_task_prefix(str(title or "").strip(), prefixes)
|
||||
return (cleaned or title or "Untitled task")[:255]
|
||||
|
||||
|
||||
async def _emit_sync_event(task: DerivedTask, event: DerivedTaskEvent, action: str) -> None:
|
||||
@@ -221,36 +307,51 @@ async def _emit_sync_event(task: DerivedTask, event: DerivedTaskEvent, action: s
|
||||
provider_settings = dict(getattr(cfg, "settings", {}) or {})
|
||||
provider = get_provider(provider_name)
|
||||
idempotency_key = f"{provider_name}:{task.id}:{event.id}"
|
||||
variants = _channel_variants(task.source_service or "", task.source_channel or "")
|
||||
person_identifier = None
|
||||
if variants:
|
||||
person_identifier = await sync_to_async(
|
||||
lambda: PersonIdentifier.objects.filter(
|
||||
user=task.user,
|
||||
service=task.source_service,
|
||||
identifier__in=variants,
|
||||
)
|
||||
.select_related("person")
|
||||
.order_by("-id")
|
||||
.first()
|
||||
)()
|
||||
external_chat_id = ""
|
||||
if person_identifier is not None:
|
||||
link = await sync_to_async(
|
||||
lambda: ExternalChatLink.objects.filter(
|
||||
user=task.user,
|
||||
provider=provider_name,
|
||||
enabled=True,
|
||||
)
|
||||
.filter(
|
||||
Q(person_identifier=person_identifier)
|
||||
| Q(person=person_identifier.person)
|
||||
)
|
||||
.order_by("-updated_at", "-id")
|
||||
.first()
|
||||
)()
|
||||
if link is not None:
|
||||
external_chat_id = str(link.external_chat_id or "").strip()
|
||||
external_chat_id = await sync_to_async(resolve_external_chat_id)(
|
||||
user=task.user,
|
||||
provider=provider_name,
|
||||
service=str(task.source_service or ""),
|
||||
channel=str(task.source_channel or ""),
|
||||
)
|
||||
cached_project = task._state.fields_cache.get("project")
|
||||
cached_epic = task._state.fields_cache.get("epic")
|
||||
project_name = str(getattr(cached_project, "name", "") or "")
|
||||
epic_name = str(getattr(cached_epic, "name", "") or "")
|
||||
request_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": project_name,
|
||||
"epic_name": epic_name,
|
||||
"source_service": str(task.source_service or ""),
|
||||
"source_channel": str(task.source_channel or ""),
|
||||
"external_chat_id": external_chat_id,
|
||||
"origin_message_id": str(getattr(task, "origin_message_id", "") or ""),
|
||||
"trigger_message_id": str(getattr(event, "source_message_id", "") or getattr(task, "origin_message_id", "") or ""),
|
||||
"mode": "default",
|
||||
"payload": event.payload,
|
||||
}
|
||||
codex_run = await sync_to_async(CodexRun.objects.create)(
|
||||
user=task.user,
|
||||
task_id=task.id,
|
||||
derived_task_event_id=event.id,
|
||||
source_message_id=(event.source_message_id or task.origin_message_id),
|
||||
project_id=task.project_id,
|
||||
epic_id=task.epic_id,
|
||||
source_service=str(task.source_service or ""),
|
||||
source_channel=str(task.source_channel or ""),
|
||||
external_chat_id=external_chat_id,
|
||||
status="queued",
|
||||
request_payload={
|
||||
"action": action,
|
||||
"provider_payload": dict(request_payload),
|
||||
"idempotency_key": idempotency_key,
|
||||
},
|
||||
result_payload={},
|
||||
error="",
|
||||
)
|
||||
request_payload["codex_run_id"] = str(codex_run.id)
|
||||
|
||||
# Worker-backed providers are queued and executed by `manage.py codex_worker`.
|
||||
if bool(getattr(provider, "run_in_worker", False)):
|
||||
@@ -264,16 +365,7 @@ async def _emit_sync_event(task: DerivedTask, event: DerivedTaskEvent, action: s
|
||||
"status": "pending",
|
||||
"payload": {
|
||||
"action": action,
|
||||
"provider_payload": {
|
||||
"task_id": str(task.id),
|
||||
"title": task.title,
|
||||
"external_key": task.external_key,
|
||||
"reference_code": task.reference_code,
|
||||
"source_service": str(task.source_service or ""),
|
||||
"source_channel": str(task.source_channel or ""),
|
||||
"external_chat_id": external_chat_id,
|
||||
"payload": event.payload,
|
||||
},
|
||||
"provider_payload": dict(request_payload),
|
||||
},
|
||||
"error": "",
|
||||
},
|
||||
@@ -281,34 +373,11 @@ async def _emit_sync_event(task: DerivedTask, event: DerivedTaskEvent, action: s
|
||||
return
|
||||
|
||||
if action == "create":
|
||||
result = provider.create_task(provider_settings, {
|
||||
"task_id": str(task.id),
|
||||
"title": task.title,
|
||||
"external_key": task.external_key,
|
||||
"reference_code": task.reference_code,
|
||||
"source_service": str(task.source_service or ""),
|
||||
"source_channel": str(task.source_channel or ""),
|
||||
"external_chat_id": external_chat_id,
|
||||
})
|
||||
result = provider.create_task(provider_settings, dict(request_payload))
|
||||
elif action == "complete":
|
||||
result = provider.mark_complete(provider_settings, {
|
||||
"task_id": str(task.id),
|
||||
"external_key": task.external_key,
|
||||
"reference_code": task.reference_code,
|
||||
"source_service": str(task.source_service or ""),
|
||||
"source_channel": str(task.source_channel or ""),
|
||||
"external_chat_id": external_chat_id,
|
||||
})
|
||||
result = provider.mark_complete(provider_settings, dict(request_payload))
|
||||
else:
|
||||
result = provider.append_update(provider_settings, {
|
||||
"task_id": str(task.id),
|
||||
"external_key": task.external_key,
|
||||
"reference_code": task.reference_code,
|
||||
"source_service": str(task.source_service or ""),
|
||||
"source_channel": str(task.source_channel or ""),
|
||||
"external_chat_id": external_chat_id,
|
||||
"payload": event.payload,
|
||||
})
|
||||
result = provider.append_update(provider_settings, dict(request_payload))
|
||||
|
||||
status = "ok" if result.ok else "failed"
|
||||
await sync_to_async(ExternalSyncEvent.objects.update_or_create)(
|
||||
@@ -323,6 +392,10 @@ async def _emit_sync_event(task: DerivedTask, event: DerivedTaskEvent, action: s
|
||||
"error": str(result.error or ""),
|
||||
},
|
||||
)
|
||||
codex_run.status = status
|
||||
codex_run.result_payload = dict(result.payload or {})
|
||||
codex_run.error = str(result.error or "")
|
||||
await sync_to_async(codex_run.save)(update_fields=["status", "result_payload", "error", "updated_at"])
|
||||
if result.ok and result.external_key and not task.external_key:
|
||||
task.external_key = str(result.external_key)
|
||||
await sync_to_async(task.save)(update_fields=["external_key"])
|
||||
@@ -338,6 +411,121 @@ async def _completion_regex(message: Message) -> re.Pattern:
|
||||
return re.compile(r"\\b(?:" + "|".join(re.escape(p) for p in phrases) + r")\\s*#([A-Za-z0-9_-]+)\\b", re.IGNORECASE)
|
||||
|
||||
|
||||
async def _send_scope_message(source: ChatTaskSource, message: Message, text: str) -> None:
|
||||
await send_message_raw(
|
||||
source.service or message.source_service or "web",
|
||||
source.channel_identifier or message.source_chat_id or "",
|
||||
text=text,
|
||||
attachments=[],
|
||||
metadata={"origin": "task_scope_command"},
|
||||
)
|
||||
|
||||
|
||||
async def _handle_scope_task_commands(message: Message, sources: list[ChatTaskSource], text: str) -> bool:
|
||||
if not sources:
|
||||
return False
|
||||
body = str(text or "").strip()
|
||||
source = sources[0]
|
||||
if _LIST_TASKS_RE.match(body):
|
||||
open_rows = await sync_to_async(list)(
|
||||
DerivedTask.objects.filter(
|
||||
user=message.user,
|
||||
project=source.project,
|
||||
source_service=source.service,
|
||||
source_channel=source.channel_identifier,
|
||||
)
|
||||
.exclude(status_snapshot="completed")
|
||||
.order_by("-created_at")[:20]
|
||||
)
|
||||
if not open_rows:
|
||||
await _send_scope_message(source, message, "[task] no open tasks in this chat.")
|
||||
return True
|
||||
lines = ["[task] open tasks:"]
|
||||
for row in open_rows:
|
||||
lines.append(f"- #{row.reference_code} {row.title}")
|
||||
await _send_scope_message(source, message, "\n".join(lines))
|
||||
return True
|
||||
|
||||
undo_match = _UNDO_TASK_RE.match(body)
|
||||
if undo_match:
|
||||
reference = str(undo_match.group("reference") or "").strip()
|
||||
if reference:
|
||||
task = await sync_to_async(
|
||||
lambda: DerivedTask.objects.filter(
|
||||
user=message.user,
|
||||
project=source.project,
|
||||
source_service=source.service,
|
||||
source_channel=source.channel_identifier,
|
||||
reference_code=reference,
|
||||
)
|
||||
.order_by("-created_at")
|
||||
.first()
|
||||
)()
|
||||
else:
|
||||
task = await sync_to_async(
|
||||
lambda: DerivedTask.objects.filter(
|
||||
user=message.user,
|
||||
project=source.project,
|
||||
source_service=source.service,
|
||||
source_channel=source.channel_identifier,
|
||||
)
|
||||
.order_by("-created_at")
|
||||
.first()
|
||||
)()
|
||||
if task is None:
|
||||
await _send_scope_message(source, message, "[task] nothing to undo in this chat.")
|
||||
return True
|
||||
ref = str(task.reference_code or "")
|
||||
title = str(task.title or "")
|
||||
await sync_to_async(task.delete)()
|
||||
await _send_scope_message(source, message, f"[task] removed #{ref}: {title}")
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _extract_epic_name_from_text(text: str) -> str:
|
||||
body = str(text or "")
|
||||
match = _EPIC_TOKEN_RE.search(body)
|
||||
if not match:
|
||||
return ""
|
||||
return str(match.group(1) or "").strip()
|
||||
|
||||
|
||||
def _strip_epic_token(text: str) -> str:
|
||||
body = str(text or "")
|
||||
cleaned = _EPIC_TOKEN_RE.sub("", body)
|
||||
return re.sub(r"\s{2,}", " ", cleaned).strip()
|
||||
|
||||
|
||||
async def _handle_epic_create_command(message: Message, sources: list[ChatTaskSource], text: str) -> bool:
|
||||
match = _EPIC_CREATE_RE.match(str(text or ""))
|
||||
if not match or not sources:
|
||||
return False
|
||||
name = str(match.group("name") or "").strip()
|
||||
if not name:
|
||||
return True
|
||||
source = sources[0]
|
||||
epic, created = await sync_to_async(TaskEpic.objects.get_or_create)(
|
||||
project=source.project,
|
||||
name=name,
|
||||
)
|
||||
state = "created" if created else "already exists"
|
||||
await _send_scope_message(
|
||||
source,
|
||||
message,
|
||||
(
|
||||
f"[epic] {state}: {epic.name}\n"
|
||||
"WhatsApp usage:\n"
|
||||
"- create epic: epic: <Epic name> (or .epic <Epic name>)\n"
|
||||
"- add task to epic: task: <description> [epic:<Epic name>]\n"
|
||||
"- list tasks: .l list tasks\n"
|
||||
"- undo latest task: .undo"
|
||||
),
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
async def process_inbound_task_intelligence(message: Message) -> None:
|
||||
if message is None:
|
||||
return
|
||||
@@ -350,6 +538,10 @@ async def process_inbound_task_intelligence(message: Message) -> None:
|
||||
sources = await _resolve_source_mappings(message)
|
||||
if not sources:
|
||||
return
|
||||
if await _handle_scope_task_commands(message, sources, text):
|
||||
return
|
||||
if await _handle_epic_create_command(message, sources, text):
|
||||
return
|
||||
|
||||
completion_allowed = any(bool(_effective_flags(source).get("completion_enabled")) for source in sources)
|
||||
completion_rx = await _completion_regex(message) if completion_allowed else None
|
||||
@@ -399,21 +591,37 @@ async def process_inbound_task_intelligence(message: Message) -> None:
|
||||
flags = _effective_flags(source)
|
||||
if not bool(flags.get("derive_enabled", True)):
|
||||
continue
|
||||
if not _is_task_candidate(text, flags):
|
||||
task_text = _strip_epic_token(text)
|
||||
if not _is_task_candidate(task_text, flags):
|
||||
continue
|
||||
title = await _derive_title_with_flags(message, flags)
|
||||
epic = source.epic
|
||||
epic_name = _extract_epic_name_from_text(text)
|
||||
if epic_name:
|
||||
epic, _ = await sync_to_async(TaskEpic.objects.get_or_create)(
|
||||
project=source.project,
|
||||
name=epic_name,
|
||||
)
|
||||
cloned_message = message
|
||||
if task_text != text:
|
||||
cloned_message = Message(
|
||||
user=message.user,
|
||||
text=task_text,
|
||||
source_service=message.source_service,
|
||||
source_chat_id=message.source_chat_id,
|
||||
)
|
||||
title = await _derive_title_with_flags(cloned_message, flags)
|
||||
reference = await sync_to_async(_next_reference)(message.user, source.project)
|
||||
task = await sync_to_async(DerivedTask.objects.create)(
|
||||
user=message.user,
|
||||
project=source.project,
|
||||
epic=source.epic,
|
||||
epic=epic,
|
||||
title=title,
|
||||
source_service=message.source_service or "web",
|
||||
source_channel=message.source_chat_id or "",
|
||||
source_service=source.service or message.source_service or "web",
|
||||
source_channel=source.channel_identifier or message.source_chat_id or "",
|
||||
origin_message=message,
|
||||
reference_code=reference,
|
||||
status_snapshot="open",
|
||||
immutable_payload={"origin_text": text, "flags": flags},
|
||||
immutable_payload={"origin_text": text, "task_text": task_text, "flags": flags},
|
||||
)
|
||||
event = await sync_to_async(DerivedTaskEvent.objects.create)(
|
||||
task=task,
|
||||
@@ -426,8 +634,8 @@ async def process_inbound_task_intelligence(message: Message) -> None:
|
||||
if bool(flags.get("announce_task_id", False)):
|
||||
try:
|
||||
await send_message_raw(
|
||||
message.source_service or "web",
|
||||
message.source_chat_id or "",
|
||||
source.service or message.source_service or "web",
|
||||
source.channel_identifier or message.source_chat_id or "",
|
||||
text=f"[task] Created #{task.reference_code}: {task.title}",
|
||||
attachments=[],
|
||||
metadata={"origin": "task_announce"},
|
||||
@@ -435,3 +643,22 @@ async def process_inbound_task_intelligence(message: Message) -> None:
|
||||
except Exception:
|
||||
# Announcement is best-effort and should not block derivation.
|
||||
pass
|
||||
scope_count = await sync_to_async(
|
||||
lambda: DerivedTask.objects.filter(
|
||||
user=message.user,
|
||||
project=source.project,
|
||||
source_service=source.service,
|
||||
source_channel=source.channel_identifier,
|
||||
).count()
|
||||
)()
|
||||
if scope_count > 0 and scope_count % 10 == 0:
|
||||
try:
|
||||
await send_message_raw(
|
||||
source.service or message.source_service or "web",
|
||||
source.channel_identifier or message.source_chat_id or "",
|
||||
text="[task] tip: use .l list tasks to review tasks. use .undo to uncreate the latest task.",
|
||||
attachments=[],
|
||||
metadata={"origin": "task_reminder"},
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -66,6 +66,14 @@ class CodexCLITaskProvider(TaskProvider):
|
||||
except Exception:
|
||||
parsed = {"raw_stdout": stdout}
|
||||
|
||||
parsed_status = str(parsed.get("status") or "").strip().lower()
|
||||
permission_request = parsed.get("permission_request")
|
||||
requires_approval = bool(
|
||||
parsed.get("requires_approval")
|
||||
or parsed_status in {"requires_approval", "waiting_approval"}
|
||||
or permission_request
|
||||
)
|
||||
|
||||
ext = (
|
||||
str(parsed.get("external_key") or "").strip()
|
||||
or str(parsed.get("task_id") or "").strip()
|
||||
@@ -78,6 +86,8 @@ class CodexCLITaskProvider(TaskProvider):
|
||||
"returncode": int(completed.returncode),
|
||||
"stdout": stdout[:4000],
|
||||
"stderr": stderr[:4000],
|
||||
"parsed_status": parsed_status,
|
||||
"requires_approval": requires_approval,
|
||||
}
|
||||
out_payload.update(parsed)
|
||||
return ProviderResult(ok=ok, external_key=ext, error=("" if ok else stderr[:4000]), payload=out_payload)
|
||||
|
||||
144
core/templates/pages/codex-settings.html
Normal file
144
core/templates/pages/codex-settings.html
Normal file
@@ -0,0 +1,144 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<section class="section">
|
||||
<div class="container">
|
||||
<h1 class="title is-4">Codex Status</h1>
|
||||
<p class="subtitle is-6">Global per-user Codex task-sync status, runs, and approvals.</p>
|
||||
|
||||
<article class="box">
|
||||
<div class="codex-inline-stats">
|
||||
<span><strong>Provider</strong> codex_cli</span>
|
||||
<span><strong>Health</strong> <span class="{% if health and health.ok %}has-text-success{% else %}has-text-danger{% endif %}">{% if health and health.ok %}online{% else %}offline{% endif %}</span></span>
|
||||
<span><strong>Pending</strong> {{ queue_counts.pending }}</span>
|
||||
<span><strong>Waiting Approval</strong> {{ queue_counts.waiting_approval }}</span>
|
||||
</div>
|
||||
{% if health and health.error %}
|
||||
<p class="help">Healthcheck error: <code>{{ health.error }}</code></p>
|
||||
{% endif %}
|
||||
<p class="help">Config snapshot: command=<code>{{ provider_settings.command }}</code>, workspace=<code>{{ provider_settings.workspace_root|default:"-" }}</code>, profile=<code>{{ provider_settings.default_profile|default:"-" }}</code>, instance=<code>{{ provider_settings.instance_label }}</code>, approver=<code>{{ provider_settings.approver_service }} {{ provider_settings.approver_identifier }}</code>.</p>
|
||||
<p class="help"><a href="{% url 'tasks_settings' %}">Edit in Task Settings</a>.</p>
|
||||
</article>
|
||||
|
||||
<article class="box">
|
||||
<h2 class="title is-6">Run Filters</h2>
|
||||
<form method="get">
|
||||
<div class="columns is-multiline">
|
||||
<div class="column is-2">
|
||||
<label class="label is-size-7">Status</label>
|
||||
<input class="input is-small" name="status" value="{{ filters.status }}" placeholder="ok/failed/...">
|
||||
</div>
|
||||
<div class="column is-2">
|
||||
<label class="label is-size-7">Service</label>
|
||||
<input class="input is-small" name="service" value="{{ filters.service }}" placeholder="signal">
|
||||
</div>
|
||||
<div class="column is-3">
|
||||
<label class="label is-size-7">Channel</label>
|
||||
<input class="input is-small" name="channel" value="{{ filters.channel }}" placeholder="identifier">
|
||||
</div>
|
||||
<div class="column is-3">
|
||||
<label class="label is-size-7">Project</label>
|
||||
<div class="select is-small is-fullwidth">
|
||||
<select name="project">
|
||||
<option value="">All</option>
|
||||
{% for row in projects %}
|
||||
<option value="{{ row.id }}" {% if filters.project == row.id|stringformat:"s" %}selected{% endif %}>{{ row.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-2">
|
||||
<label class="label is-size-7">Date From</label>
|
||||
<input class="input is-small" type="date" name="date_from" value="{{ filters.date_from }}">
|
||||
</div>
|
||||
</div>
|
||||
<button class="button is-small is-link is-light" type="submit">Apply</button>
|
||||
</form>
|
||||
</article>
|
||||
|
||||
<article class="box">
|
||||
<h2 class="title is-6">Runs</h2>
|
||||
<table class="table is-fullwidth is-size-7 is-striped">
|
||||
<thead><tr><th>When</th><th>Status</th><th>Service/Channel</th><th>Project</th><th>Task</th><th>Summary</th><th>Files</th><th>Links</th></tr></thead>
|
||||
<tbody>
|
||||
{% for run in runs %}
|
||||
<tr>
|
||||
<td>{{ run.created_at }}</td>
|
||||
<td>{{ run.status }}</td>
|
||||
<td>{{ run.source_service }} · <code>{{ run.source_channel }}</code></td>
|
||||
<td>{{ run.project.name|default:"-" }}</td>
|
||||
<td>{% if run.task %}<a href="{% url 'tasks_task' task_id=run.task.id %}">#{{ run.task.reference_code }}</a>{% else %}-{% endif %}</td>
|
||||
<td>{{ run.result_payload.summary|default:"-" }}</td>
|
||||
<td>{{ run.result_payload.files_modified_count|default:"0" }}</td>
|
||||
<td>
|
||||
<details>
|
||||
<summary>Details</summary>
|
||||
<p><strong>Request</strong></p>
|
||||
<pre>{{ run.request_payload }}</pre>
|
||||
<p><strong>Result</strong></p>
|
||||
<pre>{{ run.result_payload }}</pre>
|
||||
<p><strong>Error</strong> {{ run.error|default:"-" }}</p>
|
||||
</details>
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="8">No runs.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</article>
|
||||
|
||||
<article class="box">
|
||||
<h2 class="title is-6">Permission Queue</h2>
|
||||
<table class="table is-fullwidth is-size-7 is-striped">
|
||||
<thead><tr><th>Requested</th><th>Approval Key</th><th>Status</th><th>Summary</th><th>Permissions</th><th>Run</th><th>Task</th><th>Actions</th></tr></thead>
|
||||
<tbody>
|
||||
{% for row in permission_requests %}
|
||||
<tr>
|
||||
<td>{{ row.requested_at }}</td>
|
||||
<td><code>{{ row.approval_key }}</code></td>
|
||||
<td>{{ row.status }}</td>
|
||||
<td>{{ row.summary|default:"-" }}</td>
|
||||
<td><pre>{{ row.requested_permissions }}</pre></td>
|
||||
<td><code>{{ row.codex_run_id }}</code></td>
|
||||
<td>{% if row.codex_run.task %}<a href="{% url 'tasks_task' task_id=row.codex_run.task.id %}">#{{ row.codex_run.task.reference_code }}</a>{% else %}-{% endif %}</td>
|
||||
<td>
|
||||
{% if row.status == 'pending' %}
|
||||
<form method="post" action="{% url 'codex_approval' %}" style="display:inline;">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="request_id" value="{{ row.id }}">
|
||||
<input type="hidden" name="decision" value="approve">
|
||||
<button class="button is-small is-success is-light" type="submit">Approve</button>
|
||||
</form>
|
||||
<form method="post" action="{% url 'codex_approval' %}" style="display:inline;">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="request_id" value="{{ row.id }}">
|
||||
<input type="hidden" name="decision" value="deny">
|
||||
<button class="button is-small is-danger is-light" type="submit">Deny</button>
|
||||
</form>
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="8">No permission requests.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
<style>
|
||||
.codex-inline-stats {
|
||||
display: flex;
|
||||
gap: 0.95rem;
|
||||
flex-wrap: wrap;
|
||||
font-size: 0.92rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.codex-inline-stats span {
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
@@ -38,7 +38,7 @@
|
||||
</div>
|
||||
<div class="column is-4">
|
||||
<label class="label is-size-7" for="create_trigger_token">Primary Trigger Token</label>
|
||||
<input id="create_trigger_token" class="input is-small" name="trigger_token" value="#bp#" readonly>
|
||||
<input id="create_trigger_token" class="input is-small" name="trigger_token" value=".bp" readonly>
|
||||
</div>
|
||||
</div>
|
||||
<label class="label is-size-7" for="create_template_text">BP Template (used only by <code>bp</code> in AI mode)</label>
|
||||
@@ -446,4 +446,30 @@
|
||||
border-top: 1px solid #dbdbdb;
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
(function () {
|
||||
const commandSelect = document.getElementById("create_command_slug");
|
||||
const nameInput = document.getElementById("create_name");
|
||||
const triggerInput = document.getElementById("create_trigger_token");
|
||||
if (!commandSelect || !nameInput || !triggerInput) {
|
||||
return;
|
||||
}
|
||||
const applyDefaults = function () {
|
||||
const slug = String(commandSelect.value || "").trim().toLowerCase();
|
||||
if (slug === "codex") {
|
||||
triggerInput.value = ".codex";
|
||||
if (!nameInput.value || nameInput.value === "Business Plan") {
|
||||
nameInput.value = "Codex";
|
||||
}
|
||||
return;
|
||||
}
|
||||
triggerInput.value = ".bp";
|
||||
if (!nameInput.value || nameInput.value === "Codex") {
|
||||
nameInput.value = "Business Plan";
|
||||
}
|
||||
};
|
||||
commandSelect.addEventListener("change", applyDefaults);
|
||||
applyDefaults();
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -9,7 +9,15 @@
|
||||
· Source message <code>{{ task.origin_message_id }}</code>
|
||||
{% endif %}
|
||||
</p>
|
||||
<div class="buttons"><a class="button is-small is-light" href="{% url 'tasks_hub' %}">Back</a></div>
|
||||
<div class="buttons">
|
||||
<a class="button is-small is-light" href="{% url 'tasks_hub' %}">Back</a>
|
||||
<form method="post" action="{% url 'tasks_codex_submit' %}" style="display:inline;">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="task_id" value="{{ task.id }}">
|
||||
<input type="hidden" name="next" value="{% url 'tasks_task' task_id=task.id %}">
|
||||
<button class="button is-small is-link is-light" type="submit">Send to Codex</button>
|
||||
</form>
|
||||
</div>
|
||||
<article class="box">
|
||||
<h2 class="title is-6">Events</h2>
|
||||
<table class="table is-fullwidth is-size-7">
|
||||
@@ -58,6 +66,44 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</article>
|
||||
<article class="box">
|
||||
<h2 class="title is-6">Codex Runs</h2>
|
||||
<table class="table is-fullwidth is-size-7">
|
||||
<thead><tr><th>When</th><th>Status</th><th>Summary</th><th>Files</th><th>Error</th></tr></thead>
|
||||
<tbody>
|
||||
{% for row in codex_runs %}
|
||||
<tr>
|
||||
<td>{{ row.updated_at }}</td>
|
||||
<td>{{ row.status }}</td>
|
||||
<td>{{ row.result_payload.summary|default:"-" }}</td>
|
||||
<td>{{ row.result_payload.files_modified_count|default:"0" }}</td>
|
||||
<td>{{ row.error|default:"" }}</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="5">No Codex runs.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</article>
|
||||
<article class="box">
|
||||
<h2 class="title is-6">Permission Requests</h2>
|
||||
<table class="table is-fullwidth is-size-7">
|
||||
<thead><tr><th>When</th><th>Approval Key</th><th>Status</th><th>Summary</th><th>Resolved</th></tr></thead>
|
||||
<tbody>
|
||||
{% for row in permission_requests %}
|
||||
<tr>
|
||||
<td>{{ row.requested_at }}</td>
|
||||
<td><code>{{ row.approval_key }}</code></td>
|
||||
<td>{{ row.status }}</td>
|
||||
<td>{{ row.summary|default:"-" }}</td>
|
||||
<td>{{ row.resolved_at|default:"-" }}</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="5">No permission requests.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</article>
|
||||
</div></section>
|
||||
<style>
|
||||
.task-event-payload {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<h1 class="title is-4">Tasks</h1>
|
||||
<p class="subtitle is-6">Immutable tasks derived from chat activity.</p>
|
||||
<div class="buttons" style="margin-bottom: 0.75rem;">
|
||||
<a class="button is-small is-link is-light" href="{% url 'tasks_settings' %}">Task Settings</a>
|
||||
<a class="button is-small is-link is-light" href="{% url 'tasks_settings' %}{% if scope.person_id or scope.service or scope.identifier %}?{% if scope.person_id %}person={{ scope.person_id|urlencode }}{% endif %}{% if scope.service %}{% if scope.person_id %}&{% endif %}service={{ scope.service|urlencode }}{% endif %}{% if scope.identifier %}{% if scope.person_id or scope.service %}&{% endif %}identifier={{ scope.identifier|urlencode }}{% endif %}{% endif %}">Task Settings</a>
|
||||
</div>
|
||||
<div class="columns is-variable is-5">
|
||||
<div class="column is-4">
|
||||
@@ -134,7 +134,7 @@
|
||||
<article class="box">
|
||||
<h2 class="title is-6">Recent Derived Tasks</h2>
|
||||
<table class="table is-fullwidth is-striped is-size-7">
|
||||
<thead><tr><th>Ref</th><th>Title</th><th>Created By</th><th>Project</th><th>Status</th><th></th></tr></thead>
|
||||
<thead><tr><th>Ref</th><th>Title</th><th>Created By</th><th>Project</th><th>Status</th><th>Actions</th></tr></thead>
|
||||
<tbody>
|
||||
{% for row in tasks %}
|
||||
<tr>
|
||||
@@ -148,7 +148,15 @@
|
||||
</td>
|
||||
<td>{{ row.project.name }}{% if row.epic %} / {{ row.epic.name }}{% endif %}</td>
|
||||
<td>{{ row.status_snapshot }}</td>
|
||||
<td><a class="button is-small is-light" href="{% url 'tasks_task' task_id=row.id %}">Open</a></td>
|
||||
<td>
|
||||
<a class="button is-small is-light" href="{% url 'tasks_task' task_id=row.id %}">Open</a>
|
||||
<form method="post" action="{% url 'tasks_codex_submit' %}" style="display:inline;">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="task_id" value="{{ row.id }}">
|
||||
<input type="hidden" name="next" value="{% url 'tasks_hub' %}">
|
||||
<button class="button is-small is-link is-light" type="submit">Send to Codex</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="6">No derived tasks yet.</td></tr>
|
||||
|
||||
@@ -63,11 +63,26 @@
|
||||
<td>{{ row.title }}</td>
|
||||
<td>
|
||||
{{ row.creator_label|default:"Unknown" }}
|
||||
{% if row.creator_identifier %}
|
||||
<div class="has-text-grey"><code>{{ row.creator_identifier }}</code></div>
|
||||
{% if row.creator_compose_href %}
|
||||
<div><a class="is-size-7" href="{{ row.creator_compose_href }}">Compose</a></div>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{% if row.epic %}{{ row.epic.name }}{% else %}-{% endif %}</td>
|
||||
<td>
|
||||
<form method="post" class="is-flex" style="gap: 0.35rem; align-items: center;">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="task_set_epic">
|
||||
<input type="hidden" name="task_id" value="{{ row.id }}">
|
||||
<div class="select is-small">
|
||||
<select name="epic_id">
|
||||
<option value="">No epic</option>
|
||||
{% for epic in epics %}
|
||||
<option value="{{ epic.id }}" {% if row.epic_id == epic.id %}selected{% endif %}>{{ epic.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<button class="button is-small is-light" type="submit">Set</button>
|
||||
</form>
|
||||
</td>
|
||||
<td><a class="button is-small is-light" href="{% url 'tasks_task' task_id=row.id %}">Open</a></td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
|
||||
@@ -335,7 +335,7 @@
|
||||
<input type="hidden" name="provider" value="codex_cli">
|
||||
<label class="checkbox"><input type="checkbox" name="enabled" value="1" {% if codex_provider_config and codex_provider_config.enabled %}checked{% endif %}> Enable Codex CLI provider</label>
|
||||
<p class="help">Codex task-sync runs in a dedicated worker (<code>python manage.py codex_worker</code>).</p>
|
||||
<p class="help">This provider syncs task updates to Codex; it does not mirror whole chat threads in this phase.</p>
|
||||
<p class="help">This provider config is global per-user and shared across all projects/chats. This phase is task-sync only (no full transcript mirroring by default).</p>
|
||||
<div class="field" style="margin-top:0.5rem;">
|
||||
<label class="label is-size-7">Command</label>
|
||||
<input class="input is-small" name="command" value="{{ codex_provider_settings.command }}" placeholder="codex">
|
||||
@@ -352,10 +352,67 @@
|
||||
<label class="label is-size-7">Timeout Seconds</label>
|
||||
<input class="input is-small" type="number" min="1" name="timeout_seconds" value="{{ codex_provider_settings.timeout_seconds }}">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label is-size-7">Instance Label</label>
|
||||
<input class="input is-small" name="instance_label" value="{{ codex_provider_settings.instance_label }}" placeholder="default">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label is-size-7">Approver Service</label>
|
||||
<input class="input is-small" name="approver_service" value="{{ codex_provider_settings.approver_service }}" placeholder="signal">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label is-size-7">Approver Identifier</label>
|
||||
<input class="input is-small" name="approver_identifier" value="{{ codex_provider_settings.approver_identifier }}" placeholder="+15550000001">
|
||||
</div>
|
||||
<div style="margin-top:0.5rem;">
|
||||
<button class="button is-small is-link is-light" type="submit">Save Codex Provider</button>
|
||||
<a class="button is-small is-light" href="{% url 'codex_settings' %}">Open Codex Status</a>
|
||||
</div>
|
||||
</form>
|
||||
<hr>
|
||||
<article class="box" style="margin-top:0.5rem;">
|
||||
<h4 class="title is-7">Codex Compact Summary</h4>
|
||||
<p class="help">
|
||||
Health:
|
||||
{% if codex_compact_summary.healthcheck_ok %}
|
||||
<span class="tag is-success is-light">online</span>
|
||||
{% else %}
|
||||
<span class="tag is-danger is-light">offline</span>
|
||||
{% endif %}
|
||||
{% if codex_compact_summary.healthcheck_error %}
|
||||
<code>{{ codex_compact_summary.healthcheck_error }}</code>
|
||||
{% endif %}
|
||||
</p>
|
||||
<p class="help">
|
||||
Worker heartbeat:
|
||||
{% if codex_compact_summary.worker_heartbeat_at %}
|
||||
{{ codex_compact_summary.worker_heartbeat_at }} ({{ codex_compact_summary.worker_heartbeat_age }})
|
||||
{% else %}
|
||||
no worker activity yet
|
||||
{% endif %}
|
||||
</p>
|
||||
<div class="tags">
|
||||
<span class="tag is-light">pending {{ codex_compact_summary.queue_counts.pending }}</span>
|
||||
<span class="tag is-warning is-light">waiting_approval {{ codex_compact_summary.queue_counts.waiting_approval }}</span>
|
||||
<span class="tag is-danger is-light">failed {{ codex_compact_summary.queue_counts.failed }}</span>
|
||||
<span class="tag is-success is-light">ok {{ codex_compact_summary.queue_counts.ok }}</span>
|
||||
</div>
|
||||
<table class="table is-fullwidth is-size-7 is-striped" style="margin-top:0.5rem;">
|
||||
<thead><tr><th>When</th><th>Status</th><th>Task</th><th>Summary</th></tr></thead>
|
||||
<tbody>
|
||||
{% for run in codex_compact_summary.recent_runs %}
|
||||
<tr>
|
||||
<td>{{ run.created_at }}</td>
|
||||
<td>{{ run.status }}</td>
|
||||
<td>{% if run.task %}<a href="{% url 'tasks_task' task_id=run.task.id %}">#{{ run.task.reference_code }}</a>{% else %}-{% endif %}</td>
|
||||
<td>{{ run.result_payload.summary|default:"-" }}</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="4">No runs yet.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</article>
|
||||
<p class="help">Browse all derived tasks in <a href="{% url 'tasks_hub' %}">Tasks Hub</a>.</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -396,6 +396,8 @@
|
||||
data-summary-url="{{ compose_summary_url }}"
|
||||
data-quick-insights-url="{{ compose_quick_insights_url }}"
|
||||
data-history-sync-url="{{ compose_history_sync_url }}"
|
||||
data-react-url="{% url 'compose_react' %}"
|
||||
data-reaction-actor-prefix="web:{{ request.user.id }}:"
|
||||
data-toggle-command-url="{{ compose_toggle_command_url }}"
|
||||
data-engage-preview-url="{{ compose_engage_preview_url }}"
|
||||
data-engage-send-url="{{ compose_engage_send_url }}">
|
||||
@@ -458,11 +460,33 @@
|
||||
{% else %}
|
||||
<p class="compose-body compose-image-fallback is-hidden">(no text)</p>
|
||||
{% endif %}
|
||||
{% if service == "signal" or service == "whatsapp" %}
|
||||
<div class="compose-reaction-actions" data-message-id="{{ msg.id }}">
|
||||
<button type="button" class="compose-react-btn" data-emoji="👍" title="React with thumbs up">👍</button>
|
||||
<button type="button" class="compose-react-btn" data-emoji="❤️" title="React with heart">❤️</button>
|
||||
<button type="button" class="compose-react-btn" data-emoji="😂" title="React with laugh">😂</button>
|
||||
<button type="button" class="compose-react-btn" data-emoji="😮" title="React with surprise">😮</button>
|
||||
<button type="button" class="compose-react-btn" data-emoji="😢" title="React with sad">😢</button>
|
||||
<button type="button" class="compose-react-btn" data-emoji="😡" title="React with angry">😡</button>
|
||||
<button type="button" class="compose-react-menu-toggle" title="More reactions" aria-label="More reactions">+</button>
|
||||
<div class="compose-react-menu is-hidden" aria-label="Emoji reaction picker">
|
||||
<button type="button" class="compose-react-btn" data-emoji="👍" title="React with thumbs up">👍</button>
|
||||
<button type="button" class="compose-react-btn" data-emoji="❤️" title="React with heart">❤️</button>
|
||||
<button type="button" class="compose-react-btn" data-emoji="😂" title="React with laugh">😂</button>
|
||||
<button type="button" class="compose-react-btn" data-emoji="😮" title="React with surprise">😮</button>
|
||||
<button type="button" class="compose-react-btn" data-emoji="😢" title="React with sad">😢</button>
|
||||
<button type="button" class="compose-react-btn" data-emoji="😡" title="React with angry">😡</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if msg.reactions %}
|
||||
<div class="compose-reactions" aria-label="Message reactions">
|
||||
{% for reaction in msg.reactions %}
|
||||
<span
|
||||
class="compose-reaction-chip"
|
||||
data-emoji="{{ reaction.emoji|escape }}"
|
||||
data-actor="{{ reaction.actor|default:''|escape }}"
|
||||
data-source-service="{{ reaction.source_service|default:''|escape }}"
|
||||
title="{{ reaction.actor|default:'Unknown' }} via {{ reaction.source_service|default:'unknown'|upper }}">
|
||||
{{ reaction.emoji }}
|
||||
</span>
|
||||
@@ -935,6 +959,61 @@
|
||||
gap: 0.26rem;
|
||||
margin: 0 0 0.28rem 0;
|
||||
}
|
||||
#{{ panel_id }} .compose-reaction-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.22rem;
|
||||
margin: 0 0 0.32rem 0;
|
||||
position: relative;
|
||||
}
|
||||
#{{ panel_id }} .compose-react-btn,
|
||||
#{{ panel_id }} .compose-react-menu-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 1.45rem;
|
||||
min-width: 1.45rem;
|
||||
padding: 0 0.34rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(127, 127, 127, 0.35);
|
||||
background: rgba(127, 127, 127, 0.12);
|
||||
color: inherit;
|
||||
font-size: 0.82rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
#{{ panel_id }} .compose-react-menu-toggle {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
#{{ panel_id }} .compose-react-btn:hover,
|
||||
#{{ panel_id }} .compose-react-menu-toggle:hover {
|
||||
background: rgba(127, 127, 127, 0.18);
|
||||
border-color: rgba(127, 127, 127, 0.5);
|
||||
}
|
||||
#{{ panel_id }} .compose-react-btn:focus-visible,
|
||||
#{{ panel_id }} .compose-react-menu-toggle:focus-visible {
|
||||
outline: 2px solid rgba(60, 132, 218, 0.8);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
#{{ panel_id }} .compose-react-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 0.2rem);
|
||||
right: 0;
|
||||
z-index: 5;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.22rem;
|
||||
padding: 0.26rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(127, 127, 127, 0.4);
|
||||
background: color-mix(in srgb, Canvas 86%, transparent);
|
||||
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
#{{ panel_id }} .compose-react-menu.is-hidden {
|
||||
display: none;
|
||||
}
|
||||
#{{ panel_id }} .compose-reaction-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -2767,6 +2846,138 @@
|
||||
thread.insertBefore(row, rows[0]);
|
||||
};
|
||||
|
||||
const QUICK_REACTION_EMOJIS = ["👍", "❤️", "😂", "😮", "😢", "😡"];
|
||||
const supportsReactions = function () {
|
||||
const service = String(thread.dataset.service || "").trim().toLowerCase();
|
||||
const reactUrl = String(thread.dataset.reactUrl || "").trim();
|
||||
return !!reactUrl && (service === "signal" || service === "whatsapp");
|
||||
};
|
||||
const reactionActorKeyForService = function (service) {
|
||||
const prefix = String(thread.dataset.reactionActorPrefix || "web::");
|
||||
return prefix + String(service || "").trim().toLowerCase();
|
||||
};
|
||||
const parseBubbleReactions = function (bubble) {
|
||||
if (!bubble) {
|
||||
return [];
|
||||
}
|
||||
return Array.from(bubble.querySelectorAll(".compose-reaction-chip")).map(function (chip) {
|
||||
return {
|
||||
emoji: String(chip.dataset.emoji || chip.textContent || "").trim(),
|
||||
actor: String(chip.dataset.actor || "").trim(),
|
||||
source_service: String(chip.dataset.sourceService || "").trim().toLowerCase(),
|
||||
};
|
||||
}).filter(function (row) {
|
||||
return !!row.emoji;
|
||||
});
|
||||
};
|
||||
const renderBubbleReactions = function (bubble, reactions) {
|
||||
if (!bubble) {
|
||||
return;
|
||||
}
|
||||
const existingWrap = bubble.querySelector(".compose-reactions");
|
||||
if (existingWrap) {
|
||||
existingWrap.remove();
|
||||
}
|
||||
const rows = Array.isArray(reactions) ? reactions : [];
|
||||
if (!rows.length) {
|
||||
return;
|
||||
}
|
||||
const reactionsWrap = document.createElement("div");
|
||||
reactionsWrap.className = "compose-reactions";
|
||||
reactionsWrap.setAttribute("aria-label", "Message reactions");
|
||||
rows.forEach(function (reaction) {
|
||||
const chip = document.createElement("span");
|
||||
const emoji = String((reaction && reaction.emoji) || "").trim();
|
||||
if (!emoji) {
|
||||
return;
|
||||
}
|
||||
const actor = String((reaction && reaction.actor) || "").trim();
|
||||
const sourceService = String((reaction && reaction.source_service) || "").trim().toLowerCase();
|
||||
chip.className = "compose-reaction-chip";
|
||||
chip.textContent = emoji;
|
||||
chip.dataset.emoji = emoji;
|
||||
chip.dataset.actor = actor;
|
||||
chip.dataset.sourceService = sourceService;
|
||||
chip.title = (actor || "Unknown") + " via " + (sourceService || "unknown").toUpperCase();
|
||||
reactionsWrap.appendChild(chip);
|
||||
});
|
||||
if (reactionsWrap.children.length) {
|
||||
bubble.appendChild(reactionsWrap);
|
||||
}
|
||||
};
|
||||
const mergeOptimisticReactions = function (rows, emoji, remove, actorKey, sourceService) {
|
||||
const existing = Array.isArray(rows) ? rows.slice() : [];
|
||||
const normalizedEmoji = String(emoji || "").trim();
|
||||
const normalizedActor = String(actorKey || "").trim();
|
||||
const normalizedService = String(sourceService || "").trim().toLowerCase();
|
||||
if (!normalizedEmoji) {
|
||||
return existing;
|
||||
}
|
||||
if (remove) {
|
||||
return existing.filter(function (row) {
|
||||
return !(
|
||||
String((row && row.emoji) || "").trim() === normalizedEmoji
|
||||
&& String((row && row.actor) || "").trim() === normalizedActor
|
||||
&& String((row && row.source_service) || "").trim().toLowerCase() === normalizedService
|
||||
);
|
||||
});
|
||||
}
|
||||
const hasMatch = existing.some(function (row) {
|
||||
return (
|
||||
String((row && row.emoji) || "").trim() === normalizedEmoji
|
||||
&& String((row && row.actor) || "").trim() === normalizedActor
|
||||
&& String((row && row.source_service) || "").trim().toLowerCase() === normalizedService
|
||||
);
|
||||
});
|
||||
if (hasMatch) {
|
||||
return existing;
|
||||
}
|
||||
existing.push({
|
||||
emoji: normalizedEmoji,
|
||||
actor: normalizedActor,
|
||||
source_service: normalizedService,
|
||||
});
|
||||
return existing;
|
||||
};
|
||||
const buildReactionActions = function (messageId) {
|
||||
if (!supportsReactions()) {
|
||||
return null;
|
||||
}
|
||||
const bar = document.createElement("div");
|
||||
bar.className = "compose-reaction-actions";
|
||||
bar.dataset.messageId = String(messageId || "").trim();
|
||||
QUICK_REACTION_EMOJIS.forEach(function (emoji) {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "compose-react-btn";
|
||||
btn.dataset.emoji = emoji;
|
||||
btn.title = "React with " + emoji;
|
||||
btn.textContent = emoji;
|
||||
bar.appendChild(btn);
|
||||
});
|
||||
const toggle = document.createElement("button");
|
||||
toggle.type = "button";
|
||||
toggle.className = "compose-react-menu-toggle";
|
||||
toggle.title = "More reactions";
|
||||
toggle.setAttribute("aria-label", "More reactions");
|
||||
toggle.textContent = "+";
|
||||
bar.appendChild(toggle);
|
||||
const menu = document.createElement("div");
|
||||
menu.className = "compose-react-menu is-hidden";
|
||||
menu.setAttribute("aria-label", "Emoji reaction picker");
|
||||
QUICK_REACTION_EMOJIS.forEach(function (emoji) {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "compose-react-btn";
|
||||
btn.dataset.emoji = emoji;
|
||||
btn.title = "React with " + emoji;
|
||||
btn.textContent = emoji;
|
||||
menu.appendChild(btn);
|
||||
});
|
||||
bar.appendChild(menu);
|
||||
return bar;
|
||||
};
|
||||
|
||||
const appendBubble = function (msg) {
|
||||
const messageId = String(msg && msg.id ? msg.id : "").trim();
|
||||
if (messageId) {
|
||||
@@ -2867,22 +3078,14 @@
|
||||
fallback.textContent = "(no text)";
|
||||
bubble.appendChild(fallback);
|
||||
}
|
||||
if (Array.isArray(msg.reactions) && msg.reactions.length) {
|
||||
const reactionsWrap = document.createElement("div");
|
||||
reactionsWrap.className = "compose-reactions";
|
||||
reactionsWrap.setAttribute("aria-label", "Message reactions");
|
||||
msg.reactions.forEach(function (reaction) {
|
||||
const chip = document.createElement("span");
|
||||
chip.className = "compose-reaction-chip";
|
||||
chip.textContent = String(reaction && reaction.emoji ? reaction.emoji : "");
|
||||
chip.title =
|
||||
String((reaction && reaction.actor) || "Unknown")
|
||||
+ " via "
|
||||
+ String((reaction && reaction.source_service) || "unknown").toUpperCase();
|
||||
reactionsWrap.appendChild(chip);
|
||||
});
|
||||
bubble.appendChild(reactionsWrap);
|
||||
const reactionBar = buildReactionActions(messageId);
|
||||
if (reactionBar) {
|
||||
bubble.appendChild(reactionBar);
|
||||
}
|
||||
renderBubbleReactions(
|
||||
bubble,
|
||||
Array.isArray(msg.reactions) ? msg.reactions : []
|
||||
);
|
||||
|
||||
const meta = document.createElement("p");
|
||||
meta.className = "compose-msg-meta";
|
||||
@@ -3019,6 +3222,88 @@
|
||||
|
||||
// Delegate click on tick triggers inside thread
|
||||
thread.addEventListener("click", function (ev) {
|
||||
const menuToggleBtn = ev.target.closest && ev.target.closest(".compose-react-menu-toggle");
|
||||
if (menuToggleBtn) {
|
||||
const actions = menuToggleBtn.closest(".compose-reaction-actions");
|
||||
if (!actions) {
|
||||
return;
|
||||
}
|
||||
const menu = actions.querySelector(".compose-react-menu");
|
||||
if (!menu) {
|
||||
return;
|
||||
}
|
||||
thread.querySelectorAll(".compose-react-menu").forEach(function (node) {
|
||||
if (node !== menu) {
|
||||
node.classList.add("is-hidden");
|
||||
}
|
||||
});
|
||||
menu.classList.toggle("is-hidden");
|
||||
return;
|
||||
}
|
||||
const reactBtn = ev.target.closest && ev.target.closest(".compose-react-btn");
|
||||
if (reactBtn) {
|
||||
const emoji = String(reactBtn.dataset.emoji || "").trim();
|
||||
const row = reactBtn.closest(".compose-row");
|
||||
const bubble = reactBtn.closest(".compose-bubble");
|
||||
const service = String(thread.dataset.service || "").trim().toLowerCase();
|
||||
const reactUrl = String(thread.dataset.reactUrl || "").trim();
|
||||
if (!emoji || !row || !bubble || !reactUrl || !supportsReactions()) {
|
||||
return;
|
||||
}
|
||||
const messageId = String(row.dataset.messageId || "").trim();
|
||||
if (!messageId) {
|
||||
return;
|
||||
}
|
||||
const actorKey = reactionActorKeyForService(service);
|
||||
const existingRows = parseBubbleReactions(bubble);
|
||||
const hasMine = existingRows.some(function (item) {
|
||||
return (
|
||||
String((item && item.emoji) || "").trim() === emoji
|
||||
&& String((item && item.actor) || "").trim() === actorKey
|
||||
&& String((item && item.source_service) || "").trim().toLowerCase() === service
|
||||
);
|
||||
});
|
||||
const remove = !!hasMine;
|
||||
const optimisticRows = mergeOptimisticReactions(
|
||||
existingRows,
|
||||
emoji,
|
||||
remove,
|
||||
actorKey,
|
||||
service
|
||||
);
|
||||
renderBubbleReactions(bubble, optimisticRows);
|
||||
const actions = reactBtn.closest(".compose-reaction-actions");
|
||||
if (actions) {
|
||||
const menu = actions.querySelector(".compose-react-menu");
|
||||
if (menu) {
|
||||
menu.classList.add("is-hidden");
|
||||
}
|
||||
}
|
||||
const formData = queryParams();
|
||||
formData.set("message_id", messageId);
|
||||
formData.set("emoji", emoji);
|
||||
formData.set("remove", remove ? "1" : "0");
|
||||
postFormJson(reactUrl, formData)
|
||||
.then(function (payload) {
|
||||
if (!payload || !payload.ok) {
|
||||
renderBubbleReactions(bubble, existingRows);
|
||||
setStatus(
|
||||
String((payload && (payload.error || payload.message)) || "Reaction failed."),
|
||||
"warning"
|
||||
);
|
||||
return;
|
||||
}
|
||||
renderBubbleReactions(
|
||||
bubble,
|
||||
Array.isArray(payload.reactions) ? payload.reactions : optimisticRows
|
||||
);
|
||||
})
|
||||
.catch(function () {
|
||||
renderBubbleReactions(bubble, existingRows);
|
||||
setStatus("Reaction send failed.", "warning");
|
||||
});
|
||||
return;
|
||||
}
|
||||
const replyBtn = ev.target.closest && ev.target.closest(".compose-reply-btn");
|
||||
if (replyBtn) {
|
||||
const row = replyBtn.closest(".compose-row");
|
||||
@@ -3060,6 +3345,11 @@
|
||||
|
||||
// Close receipt popover on outside click / escape
|
||||
document.addEventListener("click", function (ev) {
|
||||
if (!ev.target.closest || !ev.target.closest(".compose-reaction-actions")) {
|
||||
thread.querySelectorAll(".compose-react-menu").forEach(function (node) {
|
||||
node.classList.add("is-hidden");
|
||||
});
|
||||
}
|
||||
if (receiptPopover.classList.contains('is-hidden')) return;
|
||||
if (receiptPopover.contains(ev.target)) return;
|
||||
if (activeReceiptBtn && activeReceiptBtn.contains(ev.target)) return;
|
||||
|
||||
@@ -79,6 +79,13 @@ class BPSubcommandTests(TransactionTestCase):
|
||||
parsed = parse_bp_subcommand("#bp set range# now")
|
||||
self.assertEqual("set_range", parsed.command)
|
||||
|
||||
def test_parser_detects_dot_prefix_forms(self):
|
||||
parsed = parse_bp_subcommand(".BP set addendum text")
|
||||
self.assertEqual("set", parsed.command)
|
||||
self.assertEqual("addendum text", parsed.remainder_text)
|
||||
parsed_range = parse_bp_subcommand(".bp set range")
|
||||
self.assertEqual("set_range", parsed_range.command)
|
||||
|
||||
def test_set_standalone_uses_remainder_only(self):
|
||||
trigger = Message.objects.create(
|
||||
user=self.user,
|
||||
|
||||
@@ -58,3 +58,16 @@ class CodexCLITaskProviderTests(SimpleTestCase):
|
||||
result = self.provider.append_update({"command": "codex", "timeout_seconds": 10}, {"task_id": "t1"})
|
||||
self.assertFalse(result.ok)
|
||||
self.assertIn("timeout", result.error)
|
||||
|
||||
@patch("core.tasks.providers.codex_cli.subprocess.run")
|
||||
def test_requires_approval_parsed_from_stdout(self, run_mock):
|
||||
run_mock.return_value = CompletedProcess(
|
||||
args=[],
|
||||
returncode=0,
|
||||
stdout='{"status":"requires_approval","approval_key":"ak-1","permission_request":{"requested_permissions":["write"]}}',
|
||||
stderr="",
|
||||
)
|
||||
result = self.provider.append_update({"command": "codex"}, {"task_id": "t1"})
|
||||
self.assertTrue(result.ok)
|
||||
self.assertTrue(bool((result.payload or {}).get("requires_approval")))
|
||||
self.assertEqual("requires_approval", (result.payload or {}).get("parsed_status"))
|
||||
|
||||
193
core/tests/test_codex_commands_phase1.py
Normal file
193
core/tests/test_codex_commands_phase1.py
Normal file
@@ -0,0 +1,193 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from asgiref.sync import async_to_sync
|
||||
from django.test import TestCase
|
||||
|
||||
from core.commands.base import CommandContext
|
||||
from core.commands.engine import process_inbound_message
|
||||
from core.commands.handlers.codex import parse_codex_command
|
||||
from core.models import (
|
||||
ChatSession,
|
||||
CommandChannelBinding,
|
||||
CommandProfile,
|
||||
CodexPermissionRequest,
|
||||
CodexRun,
|
||||
DerivedTask,
|
||||
ExternalSyncEvent,
|
||||
Message,
|
||||
Person,
|
||||
PersonIdentifier,
|
||||
TaskProject,
|
||||
TaskProviderConfig,
|
||||
User,
|
||||
)
|
||||
|
||||
|
||||
class CodexCommandParserTests(TestCase):
|
||||
def test_parse_variants(self):
|
||||
self.assertEqual("default", parse_codex_command("#codex# run this").command)
|
||||
self.assertEqual("plan", parse_codex_command("#codex plan# run this").command)
|
||||
self.assertEqual("status", parse_codex_command("#codex status#").command)
|
||||
parsed = parse_codex_command("#codex approve abc123#")
|
||||
self.assertEqual("approve", parsed.command)
|
||||
self.assertEqual("abc123", parsed.approval_key)
|
||||
self.assertEqual("default", parse_codex_command(".codex run this").command)
|
||||
self.assertEqual("plan", parse_codex_command(".CODEX plan run this").command)
|
||||
self.assertEqual("status", parse_codex_command(".codex status").command)
|
||||
parsed_dot = parse_codex_command(".codex approve abc123")
|
||||
self.assertEqual("approve", parsed_dot.command)
|
||||
self.assertEqual("abc123", parsed_dot.approval_key)
|
||||
|
||||
|
||||
class CodexCommandExecutionTests(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user("codex-cmd-user", "codex-cmd@example.com", "x")
|
||||
self.person = Person.objects.create(user=self.user, name="Codex Cmd")
|
||||
self.identifier = PersonIdentifier.objects.create(
|
||||
user=self.user,
|
||||
person=self.person,
|
||||
service="web",
|
||||
identifier="web-chan-1",
|
||||
)
|
||||
self.session = ChatSession.objects.create(user=self.user, identifier=self.identifier)
|
||||
self.project = TaskProject.objects.create(user=self.user, name="Project A")
|
||||
self.task = DerivedTask.objects.create(
|
||||
user=self.user,
|
||||
project=self.project,
|
||||
epic=None,
|
||||
title="Task A",
|
||||
source_service="web",
|
||||
source_channel="web-chan-1",
|
||||
reference_code="1",
|
||||
status_snapshot="open",
|
||||
)
|
||||
self.profile = CommandProfile.objects.create(
|
||||
user=self.user,
|
||||
slug="codex",
|
||||
name="Codex",
|
||||
enabled=True,
|
||||
trigger_token="#codex#",
|
||||
reply_required=False,
|
||||
exact_match_only=False,
|
||||
)
|
||||
CommandChannelBinding.objects.create(
|
||||
profile=self.profile,
|
||||
direction="ingress",
|
||||
service="web",
|
||||
channel_identifier="web-chan-1",
|
||||
enabled=True,
|
||||
)
|
||||
TaskProviderConfig.objects.create(
|
||||
user=self.user,
|
||||
provider="codex_cli",
|
||||
enabled=True,
|
||||
settings={
|
||||
"command": "codex",
|
||||
"workspace_root": "",
|
||||
"default_profile": "",
|
||||
"timeout_seconds": 60,
|
||||
"chat_link_mode": "task-sync",
|
||||
"instance_label": "default",
|
||||
"approver_mode": "channel",
|
||||
"approver_service": "web",
|
||||
"approver_identifier": "approver-chan",
|
||||
},
|
||||
)
|
||||
|
||||
def _msg(self, text: str, *, source_chat_id: str = "web-chan-1", reply_to=None):
|
||||
return Message.objects.create(
|
||||
user=self.user,
|
||||
session=self.session,
|
||||
sender_uuid="",
|
||||
text=text,
|
||||
ts=1000 + Message.objects.filter(user=self.user).count(),
|
||||
source_service="web",
|
||||
source_chat_id=source_chat_id,
|
||||
reply_to=reply_to,
|
||||
message_meta={},
|
||||
)
|
||||
|
||||
def test_default_submission_creates_run_and_event(self):
|
||||
trigger = self._msg("#codex# please update #1")
|
||||
results = async_to_sync(process_inbound_message)(
|
||||
CommandContext(
|
||||
service="web",
|
||||
channel_identifier="web-chan-1",
|
||||
message_id=str(trigger.id),
|
||||
user_id=self.user.id,
|
||||
message_text=str(trigger.text),
|
||||
payload={},
|
||||
)
|
||||
)
|
||||
self.assertEqual(1, len(results))
|
||||
self.assertTrue(results[0].ok)
|
||||
run = CodexRun.objects.order_by("-created_at").first()
|
||||
self.assertIsNotNone(run)
|
||||
self.assertEqual("queued", run.status)
|
||||
event = ExternalSyncEvent.objects.order_by("-created_at").first()
|
||||
self.assertEqual("pending", event.status)
|
||||
self.assertEqual("default", str((event.payload or {}).get("provider_payload", {}).get("mode") or ""))
|
||||
|
||||
def test_plan_requires_reply_anchor(self):
|
||||
trigger = self._msg("#codex plan# #1")
|
||||
results = async_to_sync(process_inbound_message)(
|
||||
CommandContext(
|
||||
service="web",
|
||||
channel_identifier="web-chan-1",
|
||||
message_id=str(trigger.id),
|
||||
user_id=self.user.id,
|
||||
message_text=str(trigger.text),
|
||||
payload={},
|
||||
)
|
||||
)
|
||||
self.assertEqual(1, len(results))
|
||||
self.assertFalse(results[0].ok)
|
||||
self.assertEqual("reply_required_for_codex_plan", results[0].error)
|
||||
|
||||
def test_approve_command_queues_resume_event(self):
|
||||
run = CodexRun.objects.create(
|
||||
user=self.user,
|
||||
task=self.task,
|
||||
project=self.project,
|
||||
source_service="web",
|
||||
source_channel="web-chan-1",
|
||||
status="waiting_approval",
|
||||
request_payload={"action": "append_update", "provider_payload": {"task_id": str(self.task.id)}},
|
||||
result_payload={},
|
||||
)
|
||||
req = CodexPermissionRequest.objects.create(
|
||||
user=self.user,
|
||||
codex_run=run,
|
||||
approval_key="ak-123",
|
||||
summary="Need approval",
|
||||
requested_permissions={"items": ["write"]},
|
||||
resume_payload={"resume": True},
|
||||
status="pending",
|
||||
)
|
||||
CommandChannelBinding.objects.create(
|
||||
profile=self.profile,
|
||||
direction="ingress",
|
||||
service="web",
|
||||
channel_identifier="approver-chan",
|
||||
enabled=True,
|
||||
)
|
||||
trigger = self._msg("#codex approve ak-123#", source_chat_id="approver-chan")
|
||||
results = async_to_sync(process_inbound_message)(
|
||||
CommandContext(
|
||||
service="web",
|
||||
channel_identifier="approver-chan",
|
||||
message_id=str(trigger.id),
|
||||
user_id=self.user.id,
|
||||
message_text=str(trigger.text),
|
||||
payload={},
|
||||
)
|
||||
)
|
||||
self.assertEqual(1, len(results))
|
||||
self.assertTrue(results[0].ok)
|
||||
req.refresh_from_db()
|
||||
run.refresh_from_db()
|
||||
self.assertEqual("approved", req.status)
|
||||
self.assertEqual("approved_waiting_resume", run.status)
|
||||
self.assertTrue(
|
||||
ExternalSyncEvent.objects.filter(idempotency_key="codex_approval:ak-123:approved", status="pending").exists()
|
||||
)
|
||||
123
core/tests/test_codex_worker_phase1.py
Normal file
123
core/tests/test_codex_worker_phase1.py
Normal file
@@ -0,0 +1,123 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
from core.management.commands.codex_worker import Command as CodexWorkerCommand
|
||||
from core.models import CodexPermissionRequest, CodexRun, ExternalSyncEvent, TaskProject, TaskProviderConfig, User
|
||||
from core.tasks.providers.base import ProviderResult
|
||||
|
||||
|
||||
class CodexWorkerPhase1Tests(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user("codex-worker-user", "codex-worker@example.com", "x")
|
||||
self.project = TaskProject.objects.create(user=self.user, name="Worker Project")
|
||||
self.cfg = TaskProviderConfig.objects.create(
|
||||
user=self.user,
|
||||
provider="codex_cli",
|
||||
enabled=True,
|
||||
settings={
|
||||
"command": "codex",
|
||||
"workspace_root": "",
|
||||
"default_profile": "",
|
||||
"timeout_seconds": 60,
|
||||
"chat_link_mode": "task-sync",
|
||||
"instance_label": "default",
|
||||
"approver_mode": "channel",
|
||||
"approver_service": "",
|
||||
"approver_identifier": "",
|
||||
},
|
||||
)
|
||||
|
||||
@patch("core.management.commands.codex_worker.get_provider")
|
||||
def test_pending_to_ok_updates_run(self, get_provider_mock):
|
||||
run = CodexRun.objects.create(
|
||||
user=self.user,
|
||||
project=self.project,
|
||||
source_service="web",
|
||||
source_channel="web-chan-1",
|
||||
status="queued",
|
||||
request_payload={},
|
||||
result_payload={},
|
||||
)
|
||||
event = ExternalSyncEvent.objects.create(
|
||||
user=self.user,
|
||||
provider="codex_cli",
|
||||
status="pending",
|
||||
payload={
|
||||
"action": "append_update",
|
||||
"provider_payload": {
|
||||
"codex_run_id": str(run.id),
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
class _Provider:
|
||||
run_in_worker = True
|
||||
|
||||
def append_update(self, config, payload):
|
||||
return ProviderResult(ok=True, payload={"status": "ok", "summary": "done"})
|
||||
|
||||
create_task = mark_complete = link_task = append_update
|
||||
|
||||
get_provider_mock.return_value = _Provider()
|
||||
CodexWorkerCommand()._run_event(event)
|
||||
|
||||
event.refresh_from_db()
|
||||
run.refresh_from_db()
|
||||
self.assertEqual("ok", event.status)
|
||||
self.assertEqual("ok", run.status)
|
||||
self.assertEqual("done", str(run.result_payload.get("summary") or ""))
|
||||
|
||||
@patch("core.management.commands.codex_worker.get_provider")
|
||||
def test_requires_approval_moves_to_waiting_and_creates_permission_request(self, get_provider_mock):
|
||||
run = CodexRun.objects.create(
|
||||
user=self.user,
|
||||
project=self.project,
|
||||
source_service="web",
|
||||
source_channel="web-chan-1",
|
||||
status="queued",
|
||||
request_payload={},
|
||||
result_payload={},
|
||||
)
|
||||
event = ExternalSyncEvent.objects.create(
|
||||
user=self.user,
|
||||
provider="codex_cli",
|
||||
status="pending",
|
||||
payload={
|
||||
"action": "append_update",
|
||||
"provider_payload": {
|
||||
"codex_run_id": str(run.id),
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
class _Provider:
|
||||
run_in_worker = True
|
||||
|
||||
def append_update(self, config, payload):
|
||||
return ProviderResult(
|
||||
ok=True,
|
||||
payload={
|
||||
"status": "requires_approval",
|
||||
"requires_approval": True,
|
||||
"approval_key": "ak-worker-1",
|
||||
"summary": "needs permissions",
|
||||
"permission_request": {"requested_permissions": ["write"]},
|
||||
"resume_payload": {"resume": True},
|
||||
},
|
||||
)
|
||||
|
||||
create_task = mark_complete = link_task = append_update
|
||||
|
||||
get_provider_mock.return_value = _Provider()
|
||||
CodexWorkerCommand()._run_event(event)
|
||||
|
||||
event.refresh_from_db()
|
||||
run.refresh_from_db()
|
||||
self.assertEqual("waiting_approval", event.status)
|
||||
self.assertEqual("waiting_approval", run.status)
|
||||
request = CodexPermissionRequest.objects.get(approval_key="ak-worker-1")
|
||||
self.assertEqual("pending", request.status)
|
||||
self.assertEqual(str(run.id), str(request.codex_run_id))
|
||||
@@ -32,6 +32,7 @@ class CommandRoutingVariantUITests(TestCase):
|
||||
self.assertContains(response, "Variant Policies")
|
||||
self.assertContains(response, "bp set range")
|
||||
self.assertContains(response, "Send status to egress")
|
||||
self.assertContains(response, "Codex (codex)")
|
||||
|
||||
def test_variant_policy_update_persists(self):
|
||||
response = self.client.post(
|
||||
|
||||
227
core/tests/test_compose_react.py
Normal file
227
core/tests/test_compose_react.py
Normal file
@@ -0,0 +1,227 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from core.models import ChatSession, Message, Person, PersonIdentifier, User
|
||||
|
||||
|
||||
class ComposeReactTests(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user("compose-react", "react@example.com", "pw")
|
||||
self.client.force_login(self.user)
|
||||
|
||||
def _build_message(self, *, service: str, identifier: str, source_message_id: str = ""):
|
||||
person = Person.objects.create(user=self.user, name=f"{service} person")
|
||||
person_identifier = PersonIdentifier.objects.create(
|
||||
user=self.user,
|
||||
person=person,
|
||||
service=service,
|
||||
identifier=identifier,
|
||||
)
|
||||
session = ChatSession.objects.create(
|
||||
user=self.user,
|
||||
identifier=person_identifier,
|
||||
)
|
||||
message = Message.objects.create(
|
||||
user=self.user,
|
||||
session=session,
|
||||
ts=1770000000123,
|
||||
sender_uuid="sender-1",
|
||||
text="hello",
|
||||
source_service=service,
|
||||
source_message_id=source_message_id or "",
|
||||
source_chat_id=identifier,
|
||||
receipt_payload={},
|
||||
)
|
||||
return person, person_identifier, message
|
||||
|
||||
@patch("core.views.compose.history.apply_reaction", new_callable=AsyncMock)
|
||||
@patch("core.views.compose.transport.send_reaction", new_callable=AsyncMock)
|
||||
def test_signal_react_uses_numeric_source_message_id_timestamp(
|
||||
self, mocked_send_reaction, mocked_apply_reaction
|
||||
):
|
||||
person, _, message = self._build_message(
|
||||
service="signal",
|
||||
identifier="+15551230000",
|
||||
source_message_id="1771234567000",
|
||||
)
|
||||
mocked_send_reaction.return_value = True
|
||||
mocked_apply_reaction.return_value = message
|
||||
|
||||
response = self.client.post(
|
||||
reverse("compose_react"),
|
||||
{
|
||||
"service": "signal",
|
||||
"identifier": "+15551230000",
|
||||
"person": str(person.id),
|
||||
"message_id": str(message.id),
|
||||
"emoji": "👍",
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(200, response.status_code)
|
||||
payload = response.json()
|
||||
self.assertTrue(payload["ok"])
|
||||
self.assertEqual("👍", payload["emoji"])
|
||||
self.assertFalse(payload["remove"])
|
||||
mocked_send_reaction.assert_awaited_once()
|
||||
_, kwargs = mocked_send_reaction.await_args
|
||||
self.assertEqual("signal", mocked_send_reaction.await_args.args[0])
|
||||
self.assertEqual("+15551230000", mocked_send_reaction.await_args.args[1])
|
||||
self.assertEqual(1771234567000, kwargs["target_timestamp"])
|
||||
self.assertEqual("sender-1", kwargs["target_author"])
|
||||
self.assertEqual("", kwargs["target_message_id"])
|
||||
|
||||
@patch("core.views.compose.history.apply_reaction", new_callable=AsyncMock)
|
||||
@patch("core.views.compose.transport.send_reaction", new_callable=AsyncMock)
|
||||
def test_whatsapp_react_uses_upstream_message_id(
|
||||
self, mocked_send_reaction, mocked_apply_reaction
|
||||
):
|
||||
person, _, message = self._build_message(
|
||||
service="whatsapp",
|
||||
identifier="12345@s.whatsapp.net",
|
||||
source_message_id="wamid.ABC123",
|
||||
)
|
||||
mocked_send_reaction.return_value = True
|
||||
mocked_apply_reaction.return_value = message
|
||||
|
||||
response = self.client.post(
|
||||
reverse("compose_react"),
|
||||
{
|
||||
"service": "whatsapp",
|
||||
"identifier": "12345@s.whatsapp.net",
|
||||
"person": str(person.id),
|
||||
"message_id": str(message.id),
|
||||
"emoji": "❤️",
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(200, response.status_code)
|
||||
payload = response.json()
|
||||
self.assertTrue(payload["ok"])
|
||||
self.assertEqual("wamid.ABC123", payload["target_upstream_id"])
|
||||
mocked_send_reaction.assert_awaited_once()
|
||||
_, kwargs = mocked_send_reaction.await_args
|
||||
self.assertEqual("wamid.ABC123", kwargs["target_message_id"])
|
||||
|
||||
@patch("core.views.compose.transport.send_reaction", new_callable=AsyncMock)
|
||||
def test_toggle_removes_existing_actor_reaction(self, mocked_send_reaction):
|
||||
person, _, message = self._build_message(
|
||||
service="signal",
|
||||
identifier="+15551230000",
|
||||
source_message_id="1771234567000",
|
||||
)
|
||||
message.receipt_payload = {
|
||||
"reactions": [
|
||||
{
|
||||
"emoji": "👍",
|
||||
"actor": f"web:{self.user.id}:signal",
|
||||
"source_service": "signal",
|
||||
"removed": False,
|
||||
}
|
||||
]
|
||||
}
|
||||
message.save(update_fields=["receipt_payload"])
|
||||
mocked_send_reaction.return_value = True
|
||||
|
||||
with patch(
|
||||
"core.views.compose.history.apply_reaction",
|
||||
new=AsyncMock(return_value=message),
|
||||
):
|
||||
response = self.client.post(
|
||||
reverse("compose_react"),
|
||||
{
|
||||
"service": "signal",
|
||||
"identifier": "+15551230000",
|
||||
"person": str(person.id),
|
||||
"message_id": str(message.id),
|
||||
"emoji": "👍",
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(200, response.status_code)
|
||||
payload = response.json()
|
||||
self.assertTrue(payload["ok"])
|
||||
self.assertTrue(payload["remove"])
|
||||
_, kwargs = mocked_send_reaction.await_args
|
||||
self.assertTrue(kwargs["remove"])
|
||||
|
||||
def test_unsupported_service_returns_error(self):
|
||||
response = self.client.post(
|
||||
reverse("compose_react"),
|
||||
{
|
||||
"service": "xmpp",
|
||||
"identifier": "someone@example.com",
|
||||
"message_id": "does-not-matter",
|
||||
"emoji": "👍",
|
||||
},
|
||||
)
|
||||
self.assertEqual(200, response.status_code)
|
||||
self.assertEqual(
|
||||
{"ok": False, "error": "service_not_supported"},
|
||||
response.json(),
|
||||
)
|
||||
|
||||
def test_missing_whatsapp_target_returns_error(self):
|
||||
person, _, message = self._build_message(
|
||||
service="whatsapp",
|
||||
identifier="12345@s.whatsapp.net",
|
||||
source_message_id="",
|
||||
)
|
||||
response = self.client.post(
|
||||
reverse("compose_react"),
|
||||
{
|
||||
"service": "whatsapp",
|
||||
"identifier": "12345@s.whatsapp.net",
|
||||
"person": str(person.id),
|
||||
"message_id": str(message.id),
|
||||
"emoji": "😂",
|
||||
},
|
||||
)
|
||||
self.assertEqual(200, response.status_code)
|
||||
self.assertEqual(
|
||||
{"ok": False, "error": "whatsapp_target_unresolvable"},
|
||||
response.json(),
|
||||
)
|
||||
|
||||
def test_compose_page_renders_reaction_actions_for_signal(self):
|
||||
person, _, _ = self._build_message(
|
||||
service="signal",
|
||||
identifier="+15551230000",
|
||||
source_message_id="1771234567000",
|
||||
)
|
||||
response = self.client.get(
|
||||
reverse("compose_page"),
|
||||
{
|
||||
"service": "signal",
|
||||
"identifier": "+15551230000",
|
||||
"person": str(person.id),
|
||||
},
|
||||
)
|
||||
self.assertEqual(200, response.status_code)
|
||||
content = response.content.decode("utf-8")
|
||||
self.assertIn("data-react-url=", content)
|
||||
self.assertIn("compose-reaction-actions", content)
|
||||
|
||||
def test_compose_page_hides_reaction_actions_for_unsupported_service(self):
|
||||
person, _, _ = self._build_message(
|
||||
service="xmpp",
|
||||
identifier="person@example.com",
|
||||
source_message_id="msg-1",
|
||||
)
|
||||
response = self.client.get(
|
||||
reverse("compose_page"),
|
||||
{
|
||||
"service": "xmpp",
|
||||
"identifier": "person@example.com",
|
||||
"person": str(person.id),
|
||||
},
|
||||
)
|
||||
self.assertEqual(200, response.status_code)
|
||||
self.assertNotIn(
|
||||
'class="compose-reaction-actions"',
|
||||
response.content.decode("utf-8"),
|
||||
)
|
||||
@@ -9,8 +9,10 @@ from core.messaging.reply_sync import extract_reply_ref, resolve_reply_target
|
||||
from core.views.compose import _command_options_for_channel
|
||||
from core.models import (
|
||||
ChatSession,
|
||||
CommandAction,
|
||||
CommandChannelBinding,
|
||||
CommandProfile,
|
||||
CommandVariantPolicy,
|
||||
Message,
|
||||
Person,
|
||||
PersonIdentifier,
|
||||
@@ -313,4 +315,124 @@ class Phase1CommandEngineTests(TestCase):
|
||||
self.assertIn("bp", names)
|
||||
self.assertIn("bp set", names)
|
||||
self.assertIn("bp set range", names)
|
||||
self.assertIn("codex", names)
|
||||
self.assertNotIn("announce task ids", names)
|
||||
|
||||
def test_first_user_codex_command_auto_enables_defaults_for_channel(self):
|
||||
CommandProfile.objects.filter(user=self.user, slug="codex").delete()
|
||||
msg = Message.objects.create(
|
||||
user=self.user,
|
||||
session=self.session,
|
||||
sender_uuid="",
|
||||
custom_author="USER",
|
||||
text="#codex status#",
|
||||
ts=6000,
|
||||
source_service="web",
|
||||
source_chat_id="web-chan-2",
|
||||
message_meta={},
|
||||
)
|
||||
results = async_to_sync(process_inbound_message)(
|
||||
CommandContext(
|
||||
service="web",
|
||||
channel_identifier="web-chan-2",
|
||||
message_id=str(msg.id),
|
||||
user_id=self.user.id,
|
||||
message_text="#codex status#",
|
||||
payload={},
|
||||
)
|
||||
)
|
||||
self.assertEqual(1, len(results))
|
||||
self.assertTrue(results[0].ok)
|
||||
profile = CommandProfile.objects.filter(user=self.user, slug="codex").first()
|
||||
self.assertIsNotNone(profile)
|
||||
self.assertTrue(bool(profile.enabled if profile else False))
|
||||
ingress_exists = CommandChannelBinding.objects.filter(
|
||||
profile=profile,
|
||||
direction="ingress",
|
||||
enabled=True,
|
||||
service="signal",
|
||||
channel_identifier="+15550000002",
|
||||
).exists()
|
||||
egress_exists = CommandChannelBinding.objects.filter(
|
||||
profile=profile,
|
||||
direction="egress",
|
||||
enabled=True,
|
||||
service="signal",
|
||||
channel_identifier="+15550000002",
|
||||
).exists()
|
||||
self.assertTrue(ingress_exists)
|
||||
self.assertTrue(egress_exists)
|
||||
|
||||
def test_first_user_bp_command_auto_setup_is_idempotent(self):
|
||||
CommandProfile.objects.filter(user=self.user, slug="bp").delete()
|
||||
msg1 = Message.objects.create(
|
||||
user=self.user,
|
||||
session=self.session,
|
||||
sender_uuid="",
|
||||
custom_author="USER",
|
||||
text="#bp#",
|
||||
ts=7000,
|
||||
source_service="web",
|
||||
source_chat_id="web-chan-3",
|
||||
message_meta={},
|
||||
)
|
||||
first_results = async_to_sync(process_inbound_message)(
|
||||
CommandContext(
|
||||
service="web",
|
||||
channel_identifier="web-chan-3",
|
||||
message_id=str(msg1.id),
|
||||
user_id=self.user.id,
|
||||
message_text="#bp#",
|
||||
payload={},
|
||||
)
|
||||
)
|
||||
self.assertEqual(1, len(first_results))
|
||||
self.assertEqual("reply_required", first_results[0].error)
|
||||
profile = CommandProfile.objects.filter(user=self.user, slug="bp").first()
|
||||
self.assertIsNotNone(profile)
|
||||
if profile is None:
|
||||
return
|
||||
self.assertEqual(3, CommandAction.objects.filter(profile=profile).count())
|
||||
self.assertEqual(3, CommandVariantPolicy.objects.filter(profile=profile).count())
|
||||
self.assertEqual(
|
||||
2,
|
||||
CommandChannelBinding.objects.filter(
|
||||
profile=profile,
|
||||
service="signal",
|
||||
channel_identifier="+15550000002",
|
||||
).count(),
|
||||
)
|
||||
|
||||
msg2 = Message.objects.create(
|
||||
user=self.user,
|
||||
session=self.session,
|
||||
sender_uuid="",
|
||||
custom_author="USER",
|
||||
text="#bp#",
|
||||
ts=8000,
|
||||
source_service="web",
|
||||
source_chat_id="web-chan-3",
|
||||
message_meta={},
|
||||
)
|
||||
second_results = async_to_sync(process_inbound_message)(
|
||||
CommandContext(
|
||||
service="web",
|
||||
channel_identifier="web-chan-3",
|
||||
message_id=str(msg2.id),
|
||||
user_id=self.user.id,
|
||||
message_text="#bp#",
|
||||
payload={},
|
||||
)
|
||||
)
|
||||
self.assertEqual(1, len(second_results))
|
||||
self.assertEqual("reply_required", second_results[0].error)
|
||||
self.assertEqual(3, CommandAction.objects.filter(profile=profile).count())
|
||||
self.assertEqual(3, CommandVariantPolicy.objects.filter(profile=profile).count())
|
||||
self.assertEqual(
|
||||
2,
|
||||
CommandChannelBinding.objects.filter(
|
||||
profile=profile,
|
||||
service="signal",
|
||||
channel_identifier="+15550000002",
|
||||
).count(),
|
||||
)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from asgiref.sync import async_to_sync
|
||||
from django.test import TestCase, override_settings
|
||||
|
||||
@@ -13,6 +15,7 @@ from core.models import (
|
||||
Person,
|
||||
PersonIdentifier,
|
||||
TaskCompletionPattern,
|
||||
TaskEpic,
|
||||
TaskProject,
|
||||
User,
|
||||
Message,
|
||||
@@ -197,3 +200,208 @@ class TaskEngineTests(TestCase):
|
||||
DerivedTask.objects.filter(origin_message=m).exists(),
|
||||
"Expected Signal UUID source chat to match source mapping by companion number.",
|
||||
)
|
||||
|
||||
def test_lenient_prefix_parsing_allows_hash_todo_in_strict_mode(self):
|
||||
source = ChatTaskSource.objects.filter(
|
||||
user=self.user,
|
||||
service="whatsapp",
|
||||
channel_identifier="120363402761690215@g.us",
|
||||
).first()
|
||||
source.settings = {
|
||||
"derive_enabled": True,
|
||||
"match_mode": "strict",
|
||||
"require_prefix": True,
|
||||
"allowed_prefixes": ["task:", "todo:"],
|
||||
"ai_title_enabled": False,
|
||||
}
|
||||
source.save(update_fields=["settings", "updated_at"])
|
||||
m = Message.objects.create(
|
||||
user=self.user,
|
||||
session=self.session,
|
||||
sender_uuid="peer",
|
||||
text="#todo: rotate SSL certs",
|
||||
ts=1400,
|
||||
source_service="whatsapp",
|
||||
source_chat_id="120363402761690215@g.us",
|
||||
)
|
||||
async_to_sync(process_inbound_task_intelligence)(m)
|
||||
task = DerivedTask.objects.filter(origin_message=m).first()
|
||||
self.assertIsNotNone(task)
|
||||
self.assertEqual("rotate SSL certs", str(task.title or ""))
|
||||
|
||||
def test_lenient_prefix_parsing_allows_task_dash_form(self):
|
||||
source = ChatTaskSource.objects.filter(
|
||||
user=self.user,
|
||||
service="whatsapp",
|
||||
channel_identifier="120363402761690215@g.us",
|
||||
).first()
|
||||
source.settings = {
|
||||
"derive_enabled": True,
|
||||
"match_mode": "strict",
|
||||
"require_prefix": True,
|
||||
"allowed_prefixes": ["task:", "todo:"],
|
||||
"ai_title_enabled": False,
|
||||
}
|
||||
source.save(update_fields=["settings", "updated_at"])
|
||||
m = Message.objects.create(
|
||||
user=self.user,
|
||||
session=self.session,
|
||||
sender_uuid="peer",
|
||||
text="task - setup password sharing",
|
||||
ts=1500,
|
||||
source_service="whatsapp",
|
||||
source_chat_id="120363402761690215@g.us",
|
||||
)
|
||||
async_to_sync(process_inbound_task_intelligence)(m)
|
||||
task = DerivedTask.objects.filter(origin_message=m).first()
|
||||
self.assertIsNotNone(task)
|
||||
self.assertEqual("setup password sharing", str(task.title or ""))
|
||||
|
||||
def test_lenient_prefix_parsing_allows_double_dot_task_form(self):
|
||||
source = ChatTaskSource.objects.filter(
|
||||
user=self.user,
|
||||
service="whatsapp",
|
||||
channel_identifier="120363402761690215@g.us",
|
||||
).first()
|
||||
source.settings = {
|
||||
"derive_enabled": True,
|
||||
"match_mode": "strict",
|
||||
"require_prefix": True,
|
||||
"allowed_prefixes": ["task:", "todo:"],
|
||||
"ai_title_enabled": False,
|
||||
}
|
||||
source.save(update_fields=["settings", "updated_at"])
|
||||
m = Message.objects.create(
|
||||
user=self.user,
|
||||
session=self.session,
|
||||
sender_uuid="peer",
|
||||
text="..task setup password sharing",
|
||||
ts=1600,
|
||||
source_service="whatsapp",
|
||||
source_chat_id="120363402761690215@g.us",
|
||||
)
|
||||
async_to_sync(process_inbound_task_intelligence)(m)
|
||||
task = DerivedTask.objects.filter(origin_message=m).first()
|
||||
self.assertIsNotNone(task)
|
||||
self.assertEqual("setup password sharing", str(task.title or ""))
|
||||
|
||||
@patch("core.tasks.engine.send_message_raw", new_callable=AsyncMock)
|
||||
def test_dot_l_lists_tasks_in_scope(self, mocked_send):
|
||||
seed = Message.objects.create(
|
||||
user=self.user,
|
||||
session=self.session,
|
||||
sender_uuid="peer",
|
||||
text="task: rotate keys",
|
||||
ts=1700,
|
||||
source_service="whatsapp",
|
||||
source_chat_id="120363402761690215@g.us",
|
||||
)
|
||||
async_to_sync(process_inbound_task_intelligence)(seed)
|
||||
cmd = Message.objects.create(
|
||||
user=self.user,
|
||||
session=self.session,
|
||||
sender_uuid="peer",
|
||||
text=".l",
|
||||
ts=1701,
|
||||
source_service="whatsapp",
|
||||
source_chat_id="120363402761690215@g.us",
|
||||
)
|
||||
async_to_sync(process_inbound_task_intelligence)(cmd)
|
||||
self.assertTrue(mocked_send.await_count >= 1)
|
||||
list_payloads = [str(call.kwargs.get("text") or "") for call in mocked_send.await_args_list]
|
||||
self.assertTrue(any("open tasks" in row.lower() for row in list_payloads))
|
||||
self.assertTrue(any("#1" in row for row in list_payloads))
|
||||
|
||||
@patch("core.tasks.engine.send_message_raw", new_callable=AsyncMock)
|
||||
def test_dot_undo_uncreates_latest_task(self, mocked_send):
|
||||
m1 = Message.objects.create(
|
||||
user=self.user,
|
||||
session=self.session,
|
||||
sender_uuid="peer",
|
||||
text="task: one",
|
||||
ts=1800,
|
||||
source_service="whatsapp",
|
||||
source_chat_id="120363402761690215@g.us",
|
||||
)
|
||||
m2 = Message.objects.create(
|
||||
user=self.user,
|
||||
session=self.session,
|
||||
sender_uuid="peer",
|
||||
text="task: two",
|
||||
ts=1801,
|
||||
source_service="whatsapp",
|
||||
source_chat_id="120363402761690215@g.us",
|
||||
)
|
||||
async_to_sync(process_inbound_task_intelligence)(m1)
|
||||
async_to_sync(process_inbound_task_intelligence)(m2)
|
||||
self.assertEqual(2, DerivedTask.objects.filter(user=self.user, project=self.project).count())
|
||||
cmd = Message.objects.create(
|
||||
user=self.user,
|
||||
session=self.session,
|
||||
sender_uuid="peer",
|
||||
text=".undo",
|
||||
ts=1802,
|
||||
source_service="whatsapp",
|
||||
source_chat_id="120363402761690215@g.us",
|
||||
)
|
||||
async_to_sync(process_inbound_task_intelligence)(cmd)
|
||||
remaining = list(
|
||||
DerivedTask.objects.filter(user=self.user, project=self.project)
|
||||
.order_by("created_at")
|
||||
.values_list("title", flat=True)
|
||||
)
|
||||
self.assertEqual(1, len(remaining))
|
||||
self.assertEqual("one", remaining[0])
|
||||
payloads = [str(call.kwargs.get("text") or "") for call in mocked_send.await_args_list]
|
||||
self.assertTrue(any("removed #2" in row.lower() for row in payloads))
|
||||
|
||||
@patch("core.tasks.engine.send_message_raw", new_callable=AsyncMock)
|
||||
def test_every_tenth_task_sends_l_and_undo_reminder(self, mocked_send):
|
||||
for idx in range(1, 11):
|
||||
m = Message.objects.create(
|
||||
user=self.user,
|
||||
session=self.session,
|
||||
sender_uuid="peer",
|
||||
text=f"task: item {idx}",
|
||||
ts=1900 + idx,
|
||||
source_service="whatsapp",
|
||||
source_chat_id="120363402761690215@g.us",
|
||||
)
|
||||
async_to_sync(process_inbound_task_intelligence)(m)
|
||||
payloads = [str(call.kwargs.get("text") or "") for call in mocked_send.await_args_list]
|
||||
self.assertTrue(
|
||||
any(".l list tasks" in row.lower() and ".undo" in row.lower() for row in payloads),
|
||||
"Expected periodic reminder to mention both .l and .undo.",
|
||||
)
|
||||
|
||||
@patch("core.tasks.engine.send_message_raw", new_callable=AsyncMock)
|
||||
def test_epic_create_command_from_chat(self, mocked_send):
|
||||
msg = Message.objects.create(
|
||||
user=self.user,
|
||||
session=self.session,
|
||||
sender_uuid="peer",
|
||||
text="epic: Security",
|
||||
ts=2001,
|
||||
source_service="whatsapp",
|
||||
source_chat_id="120363402761690215@g.us",
|
||||
)
|
||||
async_to_sync(process_inbound_task_intelligence)(msg)
|
||||
self.assertTrue(TaskEpic.objects.filter(project=self.project, name="Security").exists())
|
||||
payloads = [str(call.kwargs.get("text") or "") for call in mocked_send.await_args_list]
|
||||
self.assertTrue(any("whatsapp usage" in row.lower() for row in payloads))
|
||||
|
||||
def test_task_with_epic_token_assigns_epic(self):
|
||||
msg = Message.objects.create(
|
||||
user=self.user,
|
||||
session=self.session,
|
||||
sender_uuid="peer",
|
||||
text="task: setup ssl cert rotation [epic:Security]",
|
||||
ts=2002,
|
||||
source_service="whatsapp",
|
||||
source_chat_id="120363402761690215@g.us",
|
||||
)
|
||||
async_to_sync(process_inbound_task_intelligence)(msg)
|
||||
task = DerivedTask.objects.filter(origin_message=msg).first()
|
||||
self.assertIsNotNone(task)
|
||||
self.assertIsNotNone(task.epic)
|
||||
self.assertEqual("Security", str(task.epic.name or ""))
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
@@ -73,6 +75,16 @@ class TasksPagesManagementTests(TestCase):
|
||||
).exists()
|
||||
)
|
||||
|
||||
def test_tasks_hub_settings_link_preserves_scope_context(self):
|
||||
response = self.client.get(
|
||||
f"{reverse('tasks_hub')}?person={self.person.id}&service=signal&identifier=147e75bd-91b7-4014-b9e5-12a44b978f7b"
|
||||
)
|
||||
self.assertEqual(200, response.status_code)
|
||||
self.assertContains(
|
||||
response,
|
||||
f"{reverse('tasks_settings')}?person={self.person.id}&service=signal&identifier=147e75bd-91b7-4014-b9e5-12a44b978f7b",
|
||||
)
|
||||
|
||||
def test_project_page_can_create_and_delete_epic(self):
|
||||
project = TaskProject.objects.create(user=self.user, name="Roadmap")
|
||||
create_response = self.client.post(
|
||||
@@ -98,6 +110,81 @@ class TasksPagesManagementTests(TestCase):
|
||||
self.assertEqual(200, delete_response.status_code)
|
||||
self.assertFalse(TaskEpic.objects.filter(project=project, name="Phase 1").exists())
|
||||
|
||||
def test_project_page_can_assign_and_clear_task_epic(self):
|
||||
project = TaskProject.objects.create(user=self.user, name="Roadmap")
|
||||
epic = TaskEpic.objects.create(project=project, name="Sprint A")
|
||||
session = ChatSession.objects.create(user=self.user, identifier=self.pid_signal)
|
||||
origin = Message.objects.create(
|
||||
user=self.user,
|
||||
session=session,
|
||||
ts=1_700_000_000_100,
|
||||
text="task: assign epic",
|
||||
sender_uuid="+15551230000",
|
||||
custom_author="OTHER",
|
||||
source_service="signal",
|
||||
source_chat_id="+15551230000",
|
||||
)
|
||||
task = DerivedTask.objects.create(
|
||||
user=self.user,
|
||||
project=project,
|
||||
title="Assign me",
|
||||
source_service="signal",
|
||||
source_channel="+15551230000",
|
||||
origin_message=origin,
|
||||
reference_code="9",
|
||||
status_snapshot="open",
|
||||
)
|
||||
assign_response = self.client.post(
|
||||
reverse("tasks_project", kwargs={"project_id": str(project.id)}),
|
||||
{
|
||||
"action": "task_set_epic",
|
||||
"task_id": str(task.id),
|
||||
"epic_id": str(epic.id),
|
||||
},
|
||||
follow=True,
|
||||
)
|
||||
self.assertEqual(200, assign_response.status_code)
|
||||
task.refresh_from_db()
|
||||
self.assertEqual(epic.id, task.epic_id)
|
||||
|
||||
clear_response = self.client.post(
|
||||
reverse("tasks_project", kwargs={"project_id": str(project.id)}),
|
||||
{
|
||||
"action": "task_set_epic",
|
||||
"task_id": str(task.id),
|
||||
"epic_id": "",
|
||||
},
|
||||
follow=True,
|
||||
)
|
||||
self.assertEqual(200, clear_response.status_code)
|
||||
task.refresh_from_db()
|
||||
self.assertIsNone(task.epic_id)
|
||||
|
||||
@patch("core.views.tasks.send_message_raw", new_callable=AsyncMock)
|
||||
def test_project_epic_create_announces_to_project_chats(self, mocked_send):
|
||||
project = TaskProject.objects.create(user=self.user, name="Roadmap")
|
||||
ChatTaskSource.objects.create(
|
||||
user=self.user,
|
||||
service="whatsapp",
|
||||
channel_identifier="120363402761690215@g.us",
|
||||
project=project,
|
||||
enabled=True,
|
||||
)
|
||||
response = self.client.post(
|
||||
reverse("tasks_project", kwargs={"project_id": str(project.id)}),
|
||||
{
|
||||
"action": "epic_create",
|
||||
"name": "Phase 2",
|
||||
},
|
||||
follow=True,
|
||||
)
|
||||
self.assertEqual(200, response.status_code)
|
||||
self.assertTrue(TaskEpic.objects.filter(project=project, name="Phase 2").exists())
|
||||
self.assertTrue(mocked_send.await_count >= 1)
|
||||
payloads = [str(call.kwargs.get("text") or "") for call in mocked_send.await_args_list]
|
||||
self.assertTrue(any("whatsapp usage" in row.lower() for row in payloads))
|
||||
self.assertTrue(any("add task to epic" in row.lower() for row in payloads))
|
||||
|
||||
def test_group_page_create_and_map_project(self):
|
||||
response = self.client.post(
|
||||
reverse(
|
||||
@@ -156,6 +243,36 @@ class TasksPagesManagementTests(TestCase):
|
||||
self.assertEqual(200, response.status_code)
|
||||
self.assertContains(response, "Scope Person")
|
||||
|
||||
def test_project_page_creator_column_links_to_compose(self):
|
||||
project = TaskProject.objects.create(user=self.user, name="Creator Link Test")
|
||||
session = ChatSession.objects.create(user=self.user, identifier=self.pid_signal)
|
||||
origin = Message.objects.create(
|
||||
user=self.user,
|
||||
session=session,
|
||||
ts=1_700_000_000_111,
|
||||
text="task: creator link",
|
||||
sender_uuid="+15551230000",
|
||||
custom_author="OTHER",
|
||||
source_service="signal",
|
||||
source_chat_id="+15551230000",
|
||||
)
|
||||
DerivedTask.objects.create(
|
||||
user=self.user,
|
||||
project=project,
|
||||
title="Creator link task",
|
||||
source_service="signal",
|
||||
source_channel="+15551230000",
|
||||
origin_message=origin,
|
||||
reference_code="2",
|
||||
status_snapshot="open",
|
||||
)
|
||||
response = self.client.get(reverse("tasks_project", kwargs={"project_id": str(project.id)}))
|
||||
self.assertEqual(200, response.status_code)
|
||||
self.assertContains(
|
||||
response,
|
||||
f'{reverse("compose_page")}?service=signal&identifier=%2B15551230000&person={self.person.id}',
|
||||
)
|
||||
|
||||
def test_task_detail_renders_payload_summary_and_json(self):
|
||||
project = TaskProject.objects.create(user=self.user, name="Payload Test")
|
||||
session = ChatSession.objects.create(user=self.user, identifier=self.pid_signal)
|
||||
|
||||
@@ -9,12 +9,16 @@ from django.test import TestCase, override_settings
|
||||
from core.models import (
|
||||
ChatSession,
|
||||
ChatTaskSource,
|
||||
CodexPermissionRequest,
|
||||
CodexRun,
|
||||
DerivedTask,
|
||||
ExternalSyncEvent,
|
||||
ExternalChatLink,
|
||||
Message,
|
||||
Person,
|
||||
PersonIdentifier,
|
||||
TaskCompletionPattern,
|
||||
TaskProviderConfig,
|
||||
TaskProject,
|
||||
User,
|
||||
)
|
||||
@@ -118,6 +122,7 @@ class TaskAnnounceToggleTests(TestCase):
|
||||
self.assertIn("bp", names)
|
||||
self.assertIn("bp set", names)
|
||||
self.assertIn("bp set range", names)
|
||||
self.assertIn("codex", names)
|
||||
|
||||
|
||||
@override_settings(TASK_DERIVATION_USE_AI=False)
|
||||
@@ -268,3 +273,97 @@ class TaskSettingsExternalChatLinkScopeTests(TestCase):
|
||||
external_chat_id="codex-chat-abc",
|
||||
).exists()
|
||||
)
|
||||
|
||||
|
||||
class CodexSettingsAndSubmitTests(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user("codex-settings-user", "codex-settings@example.com", "x")
|
||||
self.client.force_login(self.user)
|
||||
self.project = TaskProject.objects.create(user=self.user, name="Codex Project")
|
||||
self.task = DerivedTask.objects.create(
|
||||
user=self.user,
|
||||
project=self.project,
|
||||
epic=None,
|
||||
title="Task X",
|
||||
source_service="web",
|
||||
source_channel="web-chan-1",
|
||||
reference_code="11",
|
||||
status_snapshot="open",
|
||||
)
|
||||
|
||||
def test_provider_update_persists_phase1_codex_settings(self):
|
||||
response = self.client.post(
|
||||
reverse("tasks_settings"),
|
||||
{
|
||||
"action": "provider_update",
|
||||
"provider": "codex_cli",
|
||||
"enabled": "1",
|
||||
"command": "codex",
|
||||
"workspace_root": "/code/xf",
|
||||
"default_profile": "default",
|
||||
"timeout_seconds": "120",
|
||||
"instance_label": "team-a",
|
||||
"approver_service": "web",
|
||||
"approver_identifier": "approver-chan",
|
||||
},
|
||||
follow=True,
|
||||
)
|
||||
self.assertEqual(200, response.status_code)
|
||||
cfg = TaskProviderConfig.objects.get(user=self.user, provider="codex_cli")
|
||||
self.assertTrue(cfg.enabled)
|
||||
self.assertEqual("team-a", str(cfg.settings.get("instance_label") or ""))
|
||||
self.assertEqual("web", str(cfg.settings.get("approver_service") or ""))
|
||||
self.assertEqual("approver-chan", str(cfg.settings.get("approver_identifier") or ""))
|
||||
|
||||
def test_task_submit_endpoint_creates_codex_run_and_event(self):
|
||||
TaskProviderConfig.objects.create(
|
||||
user=self.user,
|
||||
provider="codex_cli",
|
||||
enabled=True,
|
||||
settings={"command": "codex", "timeout_seconds": 60},
|
||||
)
|
||||
response = self.client.post(
|
||||
reverse("tasks_codex_submit"),
|
||||
{
|
||||
"task_id": str(self.task.id),
|
||||
"next": reverse("tasks_hub"),
|
||||
},
|
||||
follow=True,
|
||||
)
|
||||
self.assertEqual(200, response.status_code)
|
||||
self.assertTrue(CodexRun.objects.filter(user=self.user, task=self.task).exists())
|
||||
self.assertTrue(ExternalSyncEvent.objects.filter(user=self.user, task=self.task, provider="codex_cli").exists())
|
||||
|
||||
def test_codex_settings_page_and_approval_action(self):
|
||||
run = CodexRun.objects.create(
|
||||
user=self.user,
|
||||
task=self.task,
|
||||
project=self.project,
|
||||
source_service="web",
|
||||
source_channel="web-chan-1",
|
||||
status="waiting_approval",
|
||||
request_payload={"action": "append_update", "provider_payload": {"task_id": str(self.task.id)}},
|
||||
result_payload={},
|
||||
)
|
||||
req = CodexPermissionRequest.objects.create(
|
||||
user=self.user,
|
||||
codex_run=run,
|
||||
approval_key="approve-me",
|
||||
summary="need approval",
|
||||
requested_permissions={"items": ["write"]},
|
||||
resume_payload={"resume": True},
|
||||
status="pending",
|
||||
)
|
||||
response = self.client.get(reverse("codex_settings"))
|
||||
self.assertEqual(200, response.status_code)
|
||||
self.assertContains(response, "Codex Status")
|
||||
response = self.client.post(
|
||||
reverse("codex_approval"),
|
||||
{"request_id": str(req.id), "decision": "approve"},
|
||||
follow=True,
|
||||
)
|
||||
self.assertEqual(200, response.status_code)
|
||||
req.refresh_from_db()
|
||||
run.refresh_from_db()
|
||||
self.assertEqual("approved", req.status)
|
||||
self.assertEqual("approved_waiting_resume", run.status)
|
||||
|
||||
@@ -129,7 +129,10 @@ class CommandRoutingSettings(LoginRequiredMixin, View):
|
||||
"channel_services": ("web", "xmpp", "signal", "whatsapp"),
|
||||
"directions": ("ingress", "egress", "scratchpad_mirror"),
|
||||
"action_types": ("extract_bp", "post_result", "save_document"),
|
||||
"command_choices": (("bp", "Business Plan (bp)"),),
|
||||
"command_choices": (
|
||||
("bp", "Business Plan (bp)"),
|
||||
("codex", "Codex (codex)"),
|
||||
),
|
||||
"scope_service": scope_service,
|
||||
"scope_identifier": scope_identifier,
|
||||
"scope_variants": scope_variants,
|
||||
@@ -153,40 +156,55 @@ class CommandRoutingSettings(LoginRequiredMixin, View):
|
||||
user=request.user,
|
||||
slug=slug,
|
||||
defaults={
|
||||
"name": str(request.POST.get("name") or "Business Plan").strip()
|
||||
or "Business Plan",
|
||||
"name": str(request.POST.get("name") or ("Codex" if slug == "codex" else "Business Plan")).strip()
|
||||
or ("Codex" if slug == "codex" else "Business Plan"),
|
||||
"enabled": True,
|
||||
"trigger_token": str(
|
||||
request.POST.get("trigger_token") or "#bp#"
|
||||
request.POST.get("trigger_token")
|
||||
or (".codex" if slug == "codex" else ".bp")
|
||||
).strip()
|
||||
or "#bp#",
|
||||
or (".codex" if slug == "codex" else ".bp"),
|
||||
"template_text": str(request.POST.get("template_text") or ""),
|
||||
},
|
||||
)
|
||||
profile.name = str(request.POST.get("name") or profile.name).strip() or profile.name
|
||||
if slug == "bp":
|
||||
profile.trigger_token = "#bp#"
|
||||
profile.trigger_token = ".bp"
|
||||
profile.template_text = str(request.POST.get("template_text") or profile.template_text or "")
|
||||
profile.save(update_fields=["name", "trigger_token", "template_text", "updated_at"])
|
||||
CommandAction.objects.get_or_create(
|
||||
profile=profile,
|
||||
action_type="extract_bp",
|
||||
defaults={"enabled": True, "position": 0},
|
||||
if slug == "codex":
|
||||
profile.trigger_token = ".codex"
|
||||
profile.reply_required = False
|
||||
profile.exact_match_only = False
|
||||
profile.save(
|
||||
update_fields=[
|
||||
"name",
|
||||
"trigger_token",
|
||||
"template_text",
|
||||
"reply_required",
|
||||
"exact_match_only",
|
||||
"updated_at",
|
||||
]
|
||||
)
|
||||
# Keep legacy action rows in storage for compatibility and for
|
||||
# potential reuse by non-bp commands; bp UI now relies on
|
||||
# variant policies instead of exposing the generic action matrix.
|
||||
CommandAction.objects.get_or_create(
|
||||
profile=profile,
|
||||
action_type="save_document",
|
||||
defaults={"enabled": True, "position": 1},
|
||||
)
|
||||
CommandAction.objects.get_or_create(
|
||||
profile=profile,
|
||||
action_type="post_result",
|
||||
defaults={"enabled": True, "position": 2},
|
||||
)
|
||||
ensure_variant_policies_for_profile(profile)
|
||||
if slug == "bp":
|
||||
CommandAction.objects.get_or_create(
|
||||
profile=profile,
|
||||
action_type="extract_bp",
|
||||
defaults={"enabled": True, "position": 0},
|
||||
)
|
||||
# Keep legacy action rows in storage for compatibility and for
|
||||
# potential reuse by non-bp commands; bp UI now relies on
|
||||
# variant policies instead of exposing the generic action matrix.
|
||||
CommandAction.objects.get_or_create(
|
||||
profile=profile,
|
||||
action_type="save_document",
|
||||
defaults={"enabled": True, "position": 1},
|
||||
)
|
||||
CommandAction.objects.get_or_create(
|
||||
profile=profile,
|
||||
action_type="post_result",
|
||||
defaults={"enabled": True, "position": 2},
|
||||
)
|
||||
ensure_variant_policies_for_profile(profile)
|
||||
return self._redirect_with_scope(request)
|
||||
|
||||
if action == "profile_update":
|
||||
@@ -199,7 +217,7 @@ class CommandRoutingSettings(LoginRequiredMixin, View):
|
||||
profile.enabled = bool(request.POST.get("enabled"))
|
||||
profile.trigger_token = (
|
||||
str(request.POST.get("trigger_token") or profile.trigger_token).strip()
|
||||
or "#bp#"
|
||||
or ".bp"
|
||||
)
|
||||
profile.reply_required = bool(request.POST.get("reply_required"))
|
||||
profile.exact_match_only = bool(request.POST.get("exact_match_only"))
|
||||
|
||||
@@ -26,10 +26,12 @@ from django.utils import timezone as dj_timezone
|
||||
from django.views import View
|
||||
|
||||
from core.clients import transport
|
||||
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.messaging import ai as ai_runner
|
||||
from core.messaging import history
|
||||
from core.messaging import media_bridge
|
||||
from core.messaging.utils import messages_to_string
|
||||
from core.models import (
|
||||
@@ -1792,6 +1794,86 @@ def _build_signal_reply_metadata(reply_to: Message | None, channel_identifier: s
|
||||
return payload
|
||||
|
||||
|
||||
def _parse_bool(value, default: bool = False) -> bool:
|
||||
if value is None:
|
||||
return bool(default)
|
||||
raw = str(value).strip().lower()
|
||||
if raw in {"1", "true", "yes", "on"}:
|
||||
return True
|
||||
if raw in {"0", "false", "no", "off"}:
|
||||
return False
|
||||
return bool(default)
|
||||
|
||||
|
||||
def _reaction_actor_key(user_id, service: str) -> str:
|
||||
return f"web:{int(user_id)}:{str(service or '').strip().lower()}"
|
||||
|
||||
|
||||
def _resolve_reaction_target(message: Message, service: str, channel_identifier: str) -> dict:
|
||||
service_key = _default_service(service)
|
||||
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()
|
||||
delivered_ts = int(getattr(message, "delivered_ts", 0) or 0)
|
||||
local_ts = int(getattr(message, "ts", 0) or 0)
|
||||
|
||||
if service_key == "signal":
|
||||
target_ts = 0
|
||||
if source_message_id.isdigit():
|
||||
target_ts = int(source_message_id)
|
||||
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:
|
||||
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()
|
||||
in {"USER", "BOT"}
|
||||
):
|
||||
target_author = (
|
||||
str(getattr(settings, "SIGNAL_NUMBER", "") or "").strip() or target_author
|
||||
)
|
||||
if not target_author:
|
||||
target_author = source_chat_id or str(channel_identifier or "").strip()
|
||||
if not target_author:
|
||||
return {"error": "signal_target_author_unresolvable"}
|
||||
|
||||
return {
|
||||
"target_message_id": "",
|
||||
"target_ts": int(target_ts),
|
||||
"target_author": target_author,
|
||||
}
|
||||
|
||||
if service_key == "whatsapp":
|
||||
target_message_id = source_message_id
|
||||
target_ts = delivered_ts or local_ts
|
||||
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)
|
||||
if not target_message_id:
|
||||
return {"error": "whatsapp_target_unresolvable"}
|
||||
return {
|
||||
"target_message_id": target_message_id,
|
||||
"target_ts": int(target_ts or 0),
|
||||
"target_author": "",
|
||||
}
|
||||
|
||||
return {"error": "service_not_supported"}
|
||||
|
||||
|
||||
def _canonical_command_channel_identifier(service: str, identifier: str) -> str:
|
||||
value = str(identifier or "").strip()
|
||||
if not value:
|
||||
@@ -1818,7 +1900,7 @@ def _ensure_bp_profile_and_actions(user) -> CommandProfile:
|
||||
defaults={
|
||||
"name": "Business Plan",
|
||||
"enabled": True,
|
||||
"trigger_token": "#bp#",
|
||||
"trigger_token": ".bp",
|
||||
"reply_required": True,
|
||||
"exact_match_only": True,
|
||||
"window_scope": "conversation",
|
||||
@@ -1828,6 +1910,9 @@ def _ensure_bp_profile_and_actions(user) -> CommandProfile:
|
||||
if not profile.enabled:
|
||||
profile.enabled = True
|
||||
profile.save(update_fields=["enabled", "updated_at"])
|
||||
if str(profile.trigger_token or "").strip() != ".bp":
|
||||
profile.trigger_token = ".bp"
|
||||
profile.save(update_fields=["trigger_token", "updated_at"])
|
||||
for action_type, position in (
|
||||
("extract_bp", 0),
|
||||
("save_document", 1),
|
||||
@@ -1845,6 +1930,29 @@ def _ensure_bp_profile_and_actions(user) -> CommandProfile:
|
||||
return profile
|
||||
|
||||
|
||||
def _ensure_codex_profile(user) -> CommandProfile:
|
||||
profile, _ = CommandProfile.objects.get_or_create(
|
||||
user=user,
|
||||
slug="codex",
|
||||
defaults={
|
||||
"name": "Codex",
|
||||
"enabled": True,
|
||||
"trigger_token": ".codex",
|
||||
"reply_required": False,
|
||||
"exact_match_only": False,
|
||||
"window_scope": "conversation",
|
||||
"visibility_mode": "status_in_source",
|
||||
},
|
||||
)
|
||||
if not profile.enabled:
|
||||
profile.enabled = True
|
||||
profile.save(update_fields=["enabled", "updated_at"])
|
||||
if str(profile.trigger_token or "").strip() != ".codex":
|
||||
profile.trigger_token = ".codex"
|
||||
profile.save(update_fields=["trigger_token", "updated_at"])
|
||||
return profile
|
||||
|
||||
|
||||
def _toggle_command_for_channel(
|
||||
*,
|
||||
user,
|
||||
@@ -1860,6 +1968,8 @@ def _toggle_command_for_channel(
|
||||
|
||||
if slug == "bp":
|
||||
profile = _ensure_bp_profile_and_actions(user)
|
||||
elif slug == "codex":
|
||||
profile = _ensure_codex_profile(user)
|
||||
else:
|
||||
profile = (
|
||||
CommandProfile.objects.filter(user=user, slug=slug)
|
||||
@@ -1916,7 +2026,15 @@ def _command_options_for_channel(user, service: str, identifier: str) -> list[di
|
||||
user=user,
|
||||
slug="bp",
|
||||
name="Business Plan",
|
||||
trigger_token="#bp#",
|
||||
trigger_token=".bp",
|
||||
enabled=True,
|
||||
)
|
||||
if "codex" not in by_slug:
|
||||
by_slug["codex"] = CommandProfile(
|
||||
user=user,
|
||||
slug="codex",
|
||||
name="Codex",
|
||||
trigger_token=".codex",
|
||||
enabled=True,
|
||||
)
|
||||
slugs = sorted(by_slug.keys())
|
||||
@@ -1945,7 +2063,7 @@ def _command_options_for_channel(user, service: str, identifier: str) -> list[di
|
||||
"slug": "bp",
|
||||
"toggle_slug": "bp",
|
||||
"name": "bp",
|
||||
"trigger_token": "#bp#",
|
||||
"trigger_token": ".bp",
|
||||
"enabled_here": bool(enabled_here),
|
||||
"profile_enabled": bool(profile.enabled),
|
||||
"mode_label": str(
|
||||
@@ -1956,7 +2074,7 @@ def _command_options_for_channel(user, service: str, identifier: str) -> list[di
|
||||
"slug": "bp_set",
|
||||
"toggle_slug": "bp",
|
||||
"name": "bp set",
|
||||
"trigger_token": "#bp set#",
|
||||
"trigger_token": ".bp set",
|
||||
"enabled_here": bool(enabled_here),
|
||||
"profile_enabled": bool(profile.enabled),
|
||||
"mode_label": str(
|
||||
@@ -1967,7 +2085,7 @@ def _command_options_for_channel(user, service: str, identifier: str) -> list[di
|
||||
"slug": "bp_set_range",
|
||||
"toggle_slug": "bp",
|
||||
"name": "bp set range",
|
||||
"trigger_token": "#bp set range#",
|
||||
"trigger_token": ".bp set range",
|
||||
"enabled_here": bool(enabled_here),
|
||||
"profile_enabled": bool(profile.enabled),
|
||||
"mode_label": str(
|
||||
@@ -4484,6 +4602,7 @@ class ComposeSend(LoginRequiredMixin, View):
|
||||
payload={},
|
||||
)
|
||||
)
|
||||
async_to_sync(process_inbound_assist)(created_message)
|
||||
async_to_sync(process_inbound_translation)(created_message)
|
||||
# Notify XMPP clients from runtime so cross-platform sends appear there too.
|
||||
if base["service"] in {"signal", "whatsapp"}:
|
||||
@@ -4516,3 +4635,116 @@ class ComposeSend(LoginRequiredMixin, View):
|
||||
level="success",
|
||||
panel_id=panel_id,
|
||||
)
|
||||
|
||||
|
||||
class ComposeReact(LoginRequiredMixin, View):
|
||||
def post(self, request):
|
||||
service, identifier, person = _request_scope(request, "POST")
|
||||
service_key = _default_service(service)
|
||||
if service_key not in {"signal", "whatsapp"}:
|
||||
return JsonResponse({"ok": False, "error": "service_not_supported"})
|
||||
if not identifier and person is None:
|
||||
return JsonResponse({"ok": False, "error": "missing_scope"})
|
||||
|
||||
message_id = str(request.POST.get("message_id") or "").strip()
|
||||
emoji = str(request.POST.get("emoji") or "").strip()
|
||||
remove_raw = request.POST.get("remove")
|
||||
remove_forced = remove_raw is not None and str(remove_raw).strip() != ""
|
||||
if not message_id:
|
||||
return JsonResponse({"ok": False, "error": "message_id_required"})
|
||||
if not emoji:
|
||||
return JsonResponse({"ok": False, "error": "emoji_required"})
|
||||
|
||||
base = _context_base(request.user, service_key, identifier, person)
|
||||
person_identifier = base.get("person_identifier")
|
||||
if person_identifier is None:
|
||||
return JsonResponse({"ok": False, "error": "message_scope_unresolvable"})
|
||||
|
||||
session = ChatSession.objects.filter(
|
||||
user=request.user,
|
||||
identifier=person_identifier,
|
||||
).first()
|
||||
if session is None:
|
||||
return JsonResponse({"ok": False, "error": "session_not_found"})
|
||||
|
||||
message = Message.objects.filter(
|
||||
user=request.user,
|
||||
session=session,
|
||||
id=message_id,
|
||||
).first()
|
||||
if message is None:
|
||||
return JsonResponse({"ok": False, "error": "message_not_found"})
|
||||
|
||||
target = _resolve_reaction_target(
|
||||
message=message,
|
||||
service=service_key,
|
||||
channel_identifier=str(base.get("identifier") or "").strip(),
|
||||
)
|
||||
if target.get("error"):
|
||||
return JsonResponse({"ok": False, "error": str(target.get("error"))})
|
||||
|
||||
actor_key = _reaction_actor_key(request.user.id, service_key)
|
||||
remove = _parse_bool(remove_raw, default=False)
|
||||
if not remove_forced:
|
||||
existing_rows = list((message.receipt_payload or {}).get("reactions") or [])
|
||||
has_same_reaction = False
|
||||
for row in existing_rows:
|
||||
item = dict(row or {})
|
||||
if bool(item.get("removed")):
|
||||
continue
|
||||
if str(item.get("emoji") or "").strip() != emoji:
|
||||
continue
|
||||
if str(item.get("source_service") or "").strip().lower() != service_key:
|
||||
continue
|
||||
if str(item.get("actor") or "").strip() != actor_key:
|
||||
continue
|
||||
has_same_reaction = True
|
||||
break
|
||||
remove = bool(has_same_reaction)
|
||||
|
||||
target_ts = int(target.get("target_ts") or 0)
|
||||
target_message_id = str(target.get("target_message_id") or "").strip()
|
||||
sent = async_to_sync(transport.send_reaction)(
|
||||
service_key,
|
||||
str(base.get("identifier") or "").strip(),
|
||||
emoji=emoji,
|
||||
target_message_id=target_message_id,
|
||||
target_timestamp=target_ts if target_ts > 0 else None,
|
||||
target_author=str(target.get("target_author") or "").strip(),
|
||||
remove=bool(remove),
|
||||
)
|
||||
if not sent:
|
||||
return JsonResponse({"ok": False, "error": "reaction_send_failed"})
|
||||
|
||||
updated = async_to_sync(history.apply_reaction)(
|
||||
request.user,
|
||||
person_identifier,
|
||||
target_message_id=target_message_id,
|
||||
target_ts=target_ts,
|
||||
emoji=emoji,
|
||||
source_service=service_key,
|
||||
actor=actor_key,
|
||||
remove=bool(remove),
|
||||
payload={
|
||||
"source": "compose_react",
|
||||
"message_id": str(message.id),
|
||||
},
|
||||
)
|
||||
if updated is None:
|
||||
updated = Message.objects.filter(
|
||||
user=request.user,
|
||||
session=session,
|
||||
id=message_id,
|
||||
).first()
|
||||
serialized = _serialize_message(updated or message)
|
||||
return JsonResponse(
|
||||
{
|
||||
"ok": True,
|
||||
"message_id": str(message.id),
|
||||
"emoji": emoji,
|
||||
"remove": bool(remove),
|
||||
"target_upstream_ts": target_ts,
|
||||
"target_upstream_id": target_message_id,
|
||||
"reactions": list(serialized.get("reactions") or []),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import hashlib
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from asgiref.sync import async_to_sync
|
||||
@@ -11,12 +12,15 @@ from django.db.models import Count
|
||||
from django.urls import reverse
|
||||
from django.http import JsonResponse
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.utils import timezone
|
||||
from django.views import View
|
||||
|
||||
from core.clients.transport import send_message_raw
|
||||
from core.models import (
|
||||
AnswerSuggestionEvent,
|
||||
ChatTaskSource,
|
||||
CodexPermissionRequest,
|
||||
CodexRun,
|
||||
DerivedTask,
|
||||
DerivedTaskEvent,
|
||||
ExternalSyncEvent,
|
||||
@@ -30,6 +34,7 @@ from core.models import (
|
||||
Chat,
|
||||
ExternalChatLink,
|
||||
)
|
||||
from core.tasks.codex_support import resolve_external_chat_id
|
||||
from core.tasks.providers import get_provider
|
||||
|
||||
SAFE_TASK_FLAGS_DEFAULTS = {
|
||||
@@ -268,11 +273,47 @@ def _creator_label_for_message(user, service: str, message) -> str:
|
||||
|
||||
def _apply_task_creator_labels(user, task_rows):
|
||||
rows = list(task_rows or [])
|
||||
person_identifier_cache: dict[tuple[str, str], PersonIdentifier | None] = {}
|
||||
|
||||
def _resolve_person_identifier(service_key: str, sender_identifier: str):
|
||||
key = (str(service_key or "").strip().lower(), str(sender_identifier or "").strip())
|
||||
if key in person_identifier_cache:
|
||||
return person_identifier_cache[key]
|
||||
variants = _person_identifier_scope_variants(key[0], key[1])
|
||||
row = (
|
||||
PersonIdentifier.objects.filter(
|
||||
user=user,
|
||||
service=key[0],
|
||||
identifier__in=variants or [key[1]],
|
||||
)
|
||||
.select_related("person")
|
||||
.first()
|
||||
)
|
||||
person_identifier_cache[key] = row
|
||||
return row
|
||||
|
||||
for row in rows:
|
||||
origin = getattr(row, "origin_message", None)
|
||||
service_key = str(getattr(row, "source_service", "") or "").strip().lower()
|
||||
sender_identifier = str(getattr(origin, "sender_uuid", "") or "").strip()
|
||||
row.creator_label = _creator_label_for_message(user, service_key, origin)
|
||||
row.creator_identifier = str(getattr(origin, "sender_uuid", "") or "").strip()
|
||||
row.creator_identifier = sender_identifier
|
||||
row.creator_compose_href = ""
|
||||
if sender_identifier and service_key:
|
||||
person_identifier = _resolve_person_identifier(service_key, sender_identifier)
|
||||
compose_service = service_key
|
||||
compose_identifier = sender_identifier
|
||||
compose_person_id = ""
|
||||
if person_identifier is not None:
|
||||
compose_identifier = str(getattr(person_identifier, "identifier", "") or "").strip() or sender_identifier
|
||||
compose_person_id = str(getattr(person_identifier, "person_id", "") or "")
|
||||
query = {
|
||||
"service": compose_service,
|
||||
"identifier": compose_identifier,
|
||||
}
|
||||
if compose_person_id:
|
||||
query["person"] = compose_person_id
|
||||
row.creator_compose_href = f"{reverse('compose_page')}?{urlencode(query)}"
|
||||
return rows
|
||||
|
||||
|
||||
@@ -283,6 +324,96 @@ def _provider_row_map(user):
|
||||
}
|
||||
|
||||
|
||||
def _codex_settings_with_defaults(raw: dict | None) -> dict:
|
||||
row = dict(raw or {})
|
||||
timeout_raw = str(row.get("timeout_seconds") or "60").strip()
|
||||
try:
|
||||
timeout_seconds = max(1, int(timeout_raw))
|
||||
except Exception:
|
||||
timeout_seconds = 60
|
||||
return {
|
||||
"command": str(row.get("command") or "codex").strip() or "codex",
|
||||
"workspace_root": str(row.get("workspace_root") or "").strip(),
|
||||
"default_profile": str(row.get("default_profile") or "").strip(),
|
||||
"timeout_seconds": timeout_seconds,
|
||||
"chat_link_mode": "task-sync",
|
||||
"instance_label": str(row.get("instance_label") or "default").strip() or "default",
|
||||
"approver_service": str(row.get("approver_service") or "").strip().lower(),
|
||||
"approver_identifier": str(row.get("approver_identifier") or "").strip(),
|
||||
"approver_mode": "channel",
|
||||
}
|
||||
|
||||
|
||||
def _enqueue_codex_task_submission(
|
||||
*,
|
||||
user,
|
||||
task: DerivedTask,
|
||||
source_service: str,
|
||||
source_channel: str,
|
||||
mode: str = "default",
|
||||
command_text: str = "",
|
||||
source_message=None,
|
||||
) -> CodexRun:
|
||||
external_chat_id = resolve_external_chat_id(
|
||||
user=user,
|
||||
provider="codex_cli",
|
||||
service=source_service,
|
||||
channel=source_channel,
|
||||
)
|
||||
provider_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(task.project, "name", "") or ""),
|
||||
"epic_name": str(getattr(task.epic, "name", "") or ""),
|
||||
"source_service": str(source_service or ""),
|
||||
"source_channel": str(source_channel or ""),
|
||||
"external_chat_id": external_chat_id,
|
||||
"origin_message_id": str(getattr(task, "origin_message_id", "") or ""),
|
||||
"trigger_message_id": str(getattr(source_message, "id", "") or ""),
|
||||
"mode": str(mode or "default"),
|
||||
}
|
||||
if command_text:
|
||||
provider_payload["command_text"] = str(command_text)
|
||||
run = CodexRun.objects.create(
|
||||
user=user,
|
||||
task=task,
|
||||
source_message=source_message,
|
||||
project=task.project,
|
||||
epic=task.epic,
|
||||
source_service=str(source_service or ""),
|
||||
source_channel=str(source_channel or ""),
|
||||
external_chat_id=external_chat_id,
|
||||
status="queued",
|
||||
request_payload={"action": "append_update", "provider_payload": dict(provider_payload)},
|
||||
result_payload={},
|
||||
error="",
|
||||
)
|
||||
provider_payload["codex_run_id"] = str(run.id)
|
||||
run.request_payload = {"action": "append_update", "provider_payload": dict(provider_payload)}
|
||||
run.save(update_fields=["request_payload", "updated_at"])
|
||||
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(
|
||||
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()
|
||||
@@ -337,6 +468,41 @@ def _upsert_group_source(*, user, service: str, channel_identifier: str, project
|
||||
return source
|
||||
|
||||
|
||||
def _notify_epic_created_in_project_chats(*, project: TaskProject, epic: TaskEpic) -> None:
|
||||
rows = (
|
||||
ChatTaskSource.objects.filter(project=project, enabled=True)
|
||||
.order_by("service", "channel_identifier")
|
||||
.values_list("service", "channel_identifier")
|
||||
)
|
||||
seen: set[tuple[str, str]] = set()
|
||||
for service, channel_identifier in rows:
|
||||
svc = str(service or "").strip().lower()
|
||||
chan = str(channel_identifier or "").strip()
|
||||
if not svc or not chan:
|
||||
continue
|
||||
key = (svc, chan)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
try:
|
||||
async_to_sync(send_message_raw)(
|
||||
svc,
|
||||
chan,
|
||||
text=(
|
||||
f"[epic] Created '{epic.name}' in project '{project.name}'.\n"
|
||||
"WhatsApp usage:\n"
|
||||
"- create epic: epic: <Epic name> (or .epic <Epic name>)\n"
|
||||
"- add task to epic: task: <description> [epic:<Epic name>]\n"
|
||||
"- list tasks: .l list tasks\n"
|
||||
"- undo latest task: .undo"
|
||||
),
|
||||
attachments=[],
|
||||
metadata={"origin": "task_epic_announce"},
|
||||
)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
|
||||
def _person_identifier_scope_variants(service: str, identifier: str) -> list[str]:
|
||||
service_key = str(service or "").strip().lower()
|
||||
raw_identifier = str(identifier or "").strip()
|
||||
@@ -690,6 +856,7 @@ class TaskProjectDetail(LoginRequiredMixin, View):
|
||||
return redirect("tasks_project", project_id=str(project.id))
|
||||
if created:
|
||||
messages.success(request, f"Created epic '{epic.name}'.")
|
||||
_notify_epic_created_in_project_chats(project=project, epic=epic)
|
||||
else:
|
||||
messages.info(request, f"Epic '{epic.name}' already exists.")
|
||||
return redirect("tasks_project", project_id=str(project.id))
|
||||
@@ -701,6 +868,28 @@ class TaskProjectDetail(LoginRequiredMixin, View):
|
||||
messages.success(request, f"Deleted epic '{deleted_name}'.")
|
||||
return redirect("tasks_project", project_id=str(project.id))
|
||||
|
||||
if action == "task_set_epic":
|
||||
task = get_object_or_404(
|
||||
DerivedTask,
|
||||
id=request.POST.get("task_id"),
|
||||
user=request.user,
|
||||
project=project,
|
||||
)
|
||||
epic_id = str(request.POST.get("epic_id") or "").strip()
|
||||
epic = None
|
||||
if epic_id:
|
||||
epic = get_object_or_404(TaskEpic, id=epic_id, project=project)
|
||||
task.epic = epic
|
||||
task.save(update_fields=["epic"])
|
||||
if epic is None:
|
||||
messages.success(request, f"Cleared epic for task #{task.reference_code}.")
|
||||
else:
|
||||
messages.success(
|
||||
request,
|
||||
f"Assigned task #{task.reference_code} to epic '{epic.name}'.",
|
||||
)
|
||||
return redirect("tasks_project", project_id=str(project.id))
|
||||
|
||||
if action == "project_delete":
|
||||
deleted_name = str(project.name or "").strip() or "Project"
|
||||
project.delete()
|
||||
@@ -862,6 +1051,10 @@ class TaskDetail(LoginRequiredMixin, View):
|
||||
getattr(task, "origin_message", None),
|
||||
)
|
||||
sync_events = task.external_sync_events.order_by("-created_at")
|
||||
codex_runs = task.codex_runs.select_related("source_message").order_by("-created_at")
|
||||
permission_requests = CodexPermissionRequest.objects.filter(codex_run__task=task).select_related(
|
||||
"codex_run", "external_sync_event"
|
||||
).order_by("-requested_at")
|
||||
return render(
|
||||
request,
|
||||
self.template_name,
|
||||
@@ -869,6 +1062,8 @@ class TaskDetail(LoginRequiredMixin, View):
|
||||
"task": task,
|
||||
"events": events,
|
||||
"sync_events": sync_events,
|
||||
"codex_runs": codex_runs,
|
||||
"permission_requests": permission_requests,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -895,8 +1090,39 @@ class TaskSettings(LoginRequiredMixin, View):
|
||||
row.allowed_prefixes_csv = ",".join(row.settings_effective["allowed_prefixes"])
|
||||
provider_map = _provider_row_map(request.user)
|
||||
codex_cfg = provider_map.get("codex_cli")
|
||||
codex_settings = dict(getattr(codex_cfg, "settings", {}) or {})
|
||||
codex_settings = _codex_settings_with_defaults(dict(getattr(codex_cfg, "settings", {}) or {}))
|
||||
mock_cfg = provider_map.get("mock")
|
||||
codex_provider = get_provider("codex_cli")
|
||||
codex_healthcheck = codex_provider.healthcheck(codex_settings) if codex_cfg else None
|
||||
codex_queue_counts = {
|
||||
"pending": ExternalSyncEvent.objects.filter(
|
||||
user=request.user, provider="codex_cli", status="pending"
|
||||
).count(),
|
||||
"waiting_approval": ExternalSyncEvent.objects.filter(
|
||||
user=request.user, provider="codex_cli", status="waiting_approval"
|
||||
).count(),
|
||||
"failed": ExternalSyncEvent.objects.filter(
|
||||
user=request.user, provider="codex_cli", status="failed"
|
||||
).count(),
|
||||
"ok": ExternalSyncEvent.objects.filter(
|
||||
user=request.user, provider="codex_cli", status="ok"
|
||||
).count(),
|
||||
}
|
||||
codex_recent_runs = CodexRun.objects.filter(user=request.user).order_by("-created_at")[:10]
|
||||
latest_worker_event = (
|
||||
ExternalSyncEvent.objects.filter(
|
||||
user=request.user,
|
||||
provider="codex_cli",
|
||||
)
|
||||
.filter(status__in=["ok", "failed", "waiting_approval", "retrying"])
|
||||
.order_by("-updated_at")
|
||||
.first()
|
||||
)
|
||||
worker_heartbeat_at = getattr(latest_worker_event, "updated_at", None)
|
||||
worker_heartbeat_age = ""
|
||||
if worker_heartbeat_at is not None:
|
||||
delta_seconds = max(0, int((timezone.now() - worker_heartbeat_at).total_seconds()))
|
||||
worker_heartbeat_age = f"{delta_seconds}s ago"
|
||||
external_chat_links = list(
|
||||
ExternalChatLink.objects.filter(user=request.user).select_related(
|
||||
"person", "person_identifier"
|
||||
@@ -932,6 +1158,19 @@ class TaskSettings(LoginRequiredMixin, View):
|
||||
"default_profile": str(codex_settings.get("default_profile") or ""),
|
||||
"timeout_seconds": int(codex_settings.get("timeout_seconds") or 60),
|
||||
"chat_link_mode": str(codex_settings.get("chat_link_mode") or "task-sync"),
|
||||
"instance_label": str(codex_settings.get("instance_label") or "default"),
|
||||
"approver_service": str(codex_settings.get("approver_service") or ""),
|
||||
"approver_identifier": str(codex_settings.get("approver_identifier") or ""),
|
||||
"approver_mode": "channel",
|
||||
},
|
||||
"codex_compact_summary": {
|
||||
"healthcheck_ok": bool(getattr(codex_healthcheck, "ok", False)),
|
||||
"healthcheck_error": str(getattr(codex_healthcheck, "error", "") or ""),
|
||||
"healthcheck_payload": dict(getattr(codex_healthcheck, "payload", {}) or {}),
|
||||
"worker_heartbeat_at": worker_heartbeat_at,
|
||||
"worker_heartbeat_age": worker_heartbeat_age,
|
||||
"queue_counts": codex_queue_counts,
|
||||
"recent_runs": codex_recent_runs,
|
||||
},
|
||||
"person_identifiers": person_identifiers,
|
||||
"external_link_person_identifiers": external_link_person_identifiers,
|
||||
@@ -961,12 +1200,13 @@ class TaskSettings(LoginRequiredMixin, View):
|
||||
|
||||
if action == "epic_create":
|
||||
project = get_object_or_404(TaskProject, id=request.POST.get("project_id"), user=request.user)
|
||||
TaskEpic.objects.create(
|
||||
epic = TaskEpic.objects.create(
|
||||
project=project,
|
||||
name=str(request.POST.get("name") or "Epic").strip() or "Epic",
|
||||
external_key=str(request.POST.get("external_key") or "").strip(),
|
||||
active=bool(request.POST.get("active") or "1"),
|
||||
)
|
||||
_notify_epic_created_in_project_chats(project=project, epic=epic)
|
||||
return _settings_redirect(request)
|
||||
|
||||
if action == "source_create":
|
||||
@@ -1063,18 +1303,18 @@ class TaskSettings(LoginRequiredMixin, View):
|
||||
row.enabled = bool(request.POST.get("enabled"))
|
||||
settings_payload = dict(row.settings or {})
|
||||
if provider == "codex_cli":
|
||||
timeout_raw = str(request.POST.get("timeout_seconds") or "60").strip()
|
||||
try:
|
||||
timeout_value = max(1, int(timeout_raw))
|
||||
except Exception:
|
||||
timeout_value = 60
|
||||
settings_payload = {
|
||||
"command": str(request.POST.get("command") or "codex").strip() or "codex",
|
||||
"workspace_root": str(request.POST.get("workspace_root") or "").strip(),
|
||||
"default_profile": str(request.POST.get("default_profile") or "").strip(),
|
||||
"timeout_seconds": timeout_value,
|
||||
"chat_link_mode": "task-sync",
|
||||
}
|
||||
settings_payload = _codex_settings_with_defaults(
|
||||
{
|
||||
"command": request.POST.get("command"),
|
||||
"workspace_root": request.POST.get("workspace_root"),
|
||||
"default_profile": request.POST.get("default_profile"),
|
||||
"timeout_seconds": request.POST.get("timeout_seconds"),
|
||||
"instance_label": request.POST.get("instance_label"),
|
||||
"approver_service": request.POST.get("approver_service"),
|
||||
"approver_identifier": request.POST.get("approver_identifier"),
|
||||
"approver_mode": "channel",
|
||||
}
|
||||
)
|
||||
row.settings = settings_payload
|
||||
row.save(update_fields=["enabled", "settings", "updated_at"])
|
||||
return _settings_redirect(request)
|
||||
@@ -1159,6 +1399,196 @@ class TaskSettings(LoginRequiredMixin, View):
|
||||
return _settings_redirect(request)
|
||||
|
||||
|
||||
class TaskCodexSubmit(LoginRequiredMixin, View):
|
||||
def post(self, request):
|
||||
task_id = str(request.POST.get("task_id") or "").strip()
|
||||
next_url = str(request.POST.get("next") or reverse("tasks_hub")).strip()
|
||||
task = get_object_or_404(
|
||||
DerivedTask.objects.select_related("project", "epic", "origin_message"),
|
||||
id=task_id,
|
||||
user=request.user,
|
||||
)
|
||||
cfg = TaskProviderConfig.objects.filter(
|
||||
user=request.user,
|
||||
provider="codex_cli",
|
||||
enabled=True,
|
||||
).first()
|
||||
if cfg is None:
|
||||
messages.error(
|
||||
request,
|
||||
"Codex provider is disabled. Enable it in Task Settings first.",
|
||||
)
|
||||
return redirect(next_url)
|
||||
run = _enqueue_codex_task_submission(
|
||||
user=request.user,
|
||||
task=task,
|
||||
source_service=str(task.source_service or ""),
|
||||
source_channel=str(task.source_channel or ""),
|
||||
mode="default",
|
||||
source_message=getattr(task, "origin_message", None),
|
||||
)
|
||||
messages.success(request, f"Sent task #{task.reference_code} to Codex (run {run.id}).")
|
||||
return redirect(next_url)
|
||||
|
||||
|
||||
class CodexSettingsPage(LoginRequiredMixin, View):
|
||||
template_name = "pages/codex-settings.html"
|
||||
|
||||
def _context(self, request):
|
||||
cfg = TaskProviderConfig.objects.filter(user=request.user, provider="codex_cli").first()
|
||||
settings_payload = _codex_settings_with_defaults(dict(getattr(cfg, "settings", {}) or {}))
|
||||
provider = get_provider("codex_cli")
|
||||
health = provider.healthcheck(settings_payload) if cfg else None
|
||||
|
||||
status_filter = str(request.GET.get("status") or "").strip().lower()
|
||||
service_filter = str(request.GET.get("service") or "").strip().lower()
|
||||
channel_filter = str(request.GET.get("channel") or "").strip()
|
||||
project_filter = str(request.GET.get("project") or "").strip()
|
||||
date_from = str(request.GET.get("date_from") or "").strip()
|
||||
|
||||
runs = CodexRun.objects.filter(user=request.user).select_related("task", "project", "epic").order_by("-created_at")
|
||||
if status_filter:
|
||||
runs = runs.filter(status=status_filter)
|
||||
if service_filter:
|
||||
runs = runs.filter(source_service=service_filter)
|
||||
if channel_filter:
|
||||
runs = runs.filter(source_channel=channel_filter)
|
||||
if project_filter:
|
||||
runs = runs.filter(project_id=project_filter)
|
||||
if date_from:
|
||||
runs = runs.filter(created_at__date__gte=date_from)
|
||||
runs = runs[:200]
|
||||
|
||||
queue_counts = {
|
||||
"pending": ExternalSyncEvent.objects.filter(
|
||||
user=request.user, provider="codex_cli", status="pending"
|
||||
).count(),
|
||||
"waiting_approval": ExternalSyncEvent.objects.filter(
|
||||
user=request.user, provider="codex_cli", status="waiting_approval"
|
||||
).count(),
|
||||
"failed": ExternalSyncEvent.objects.filter(
|
||||
user=request.user, provider="codex_cli", status="failed"
|
||||
).count(),
|
||||
"ok": ExternalSyncEvent.objects.filter(
|
||||
user=request.user, provider="codex_cli", status="ok"
|
||||
).count(),
|
||||
}
|
||||
permission_requests = (
|
||||
CodexPermissionRequest.objects.filter(user=request.user)
|
||||
.select_related("codex_run", "codex_run__task", "external_sync_event")
|
||||
.order_by("-requested_at")[:200]
|
||||
)
|
||||
return {
|
||||
"provider_config": cfg,
|
||||
"provider_settings": settings_payload,
|
||||
"health": health,
|
||||
"runs": runs,
|
||||
"queue_counts": queue_counts,
|
||||
"permission_requests": permission_requests,
|
||||
"projects": TaskProject.objects.filter(user=request.user).order_by("name"),
|
||||
"filters": {
|
||||
"status": status_filter,
|
||||
"service": service_filter,
|
||||
"channel": channel_filter,
|
||||
"project": project_filter,
|
||||
"date_from": date_from,
|
||||
},
|
||||
}
|
||||
|
||||
def get(self, request):
|
||||
return render(request, self.template_name, self._context(request))
|
||||
|
||||
|
||||
class CodexApprovalAction(LoginRequiredMixin, View):
|
||||
def post(self, request):
|
||||
request_id = str(request.POST.get("request_id") or "").strip()
|
||||
decision = str(request.POST.get("decision") or "").strip().lower()
|
||||
row = get_object_or_404(
|
||||
CodexPermissionRequest.objects.select_related("codex_run", "external_sync_event"),
|
||||
id=request_id,
|
||||
user=request.user,
|
||||
)
|
||||
if row.status != "pending":
|
||||
return redirect("codex_settings")
|
||||
now = timezone.now()
|
||||
if decision == "approve":
|
||||
row.status = "approved"
|
||||
row.resolved_at = now
|
||||
row.resolved_by_identifier = "settings_ui"
|
||||
row.resolution_note = "approved via settings ui"
|
||||
row.save(
|
||||
update_fields=[
|
||||
"status",
|
||||
"resolved_at",
|
||||
"resolved_by_identifier",
|
||||
"resolution_note",
|
||||
]
|
||||
)
|
||||
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),
|
||||
}
|
||||
)
|
||||
ExternalSyncEvent.objects.update_or_create(
|
||||
idempotency_key=f"codex_approval:{row.approval_key}:approved",
|
||||
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},
|
||||
"error": "",
|
||||
},
|
||||
)
|
||||
messages.success(request, f"Approved {row.approval_key}. Resume event queued.")
|
||||
return redirect("codex_settings")
|
||||
|
||||
row.status = "denied"
|
||||
row.resolved_at = now
|
||||
row.resolved_by_identifier = "settings_ui"
|
||||
row.resolution_note = "denied via settings ui"
|
||||
row.save(update_fields=["status", "resolved_at", "resolved_by_identifier", "resolution_note"])
|
||||
run = row.codex_run
|
||||
run.status = "denied"
|
||||
run.error = "approval_denied"
|
||||
run.save(update_fields=["status", "error", "updated_at"])
|
||||
ExternalSyncEvent.objects.update_or_create(
|
||||
idempotency_key=f"codex_approval:{row.approval_key}:denied",
|
||||
defaults={
|
||||
"user": request.user,
|
||||
"task": run.task,
|
||||
"task_event": run.derived_task_event,
|
||||
"provider": "codex_cli",
|
||||
"status": "failed",
|
||||
"payload": {
|
||||
"action": "append_update",
|
||||
"provider_payload": {
|
||||
"mode": "approval_response",
|
||||
"approval_key": row.approval_key,
|
||||
"codex_run_id": str(run.id),
|
||||
},
|
||||
},
|
||||
"error": "approval_denied",
|
||||
},
|
||||
)
|
||||
if row.external_sync_event_id:
|
||||
ExternalSyncEvent.objects.filter(id=row.external_sync_event_id).update(
|
||||
status="failed",
|
||||
error="approval_denied",
|
||||
)
|
||||
messages.warning(request, f"Denied {row.approval_key}.")
|
||||
return redirect("codex_settings")
|
||||
|
||||
|
||||
class AnswerSuggestionSend(LoginRequiredMixin, View):
|
||||
def post(self, request):
|
||||
event = get_object_or_404(
|
||||
|
||||
Reference in New Issue
Block a user