Implement plans
This commit is contained in:
20
app/urls.py
20
app/urls.py
@@ -193,6 +193,11 @@ urlpatterns = [
|
|||||||
compose.ComposeSend.as_view(),
|
compose.ComposeSend.as_view(),
|
||||||
name="compose_send",
|
name="compose_send",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"compose/react/",
|
||||||
|
compose.ComposeReact.as_view(),
|
||||||
|
name="compose_react",
|
||||||
|
),
|
||||||
path(
|
path(
|
||||||
"compose/cancel-send/",
|
"compose/cancel-send/",
|
||||||
compose.ComposeCancelSend.as_view(),
|
compose.ComposeCancelSend.as_view(),
|
||||||
@@ -303,11 +308,26 @@ urlpatterns = [
|
|||||||
tasks.TaskDetail.as_view(),
|
tasks.TaskDetail.as_view(),
|
||||||
name="tasks_task",
|
name="tasks_task",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"tasks/codex/submit/",
|
||||||
|
tasks.TaskCodexSubmit.as_view(),
|
||||||
|
name="tasks_codex_submit",
|
||||||
|
),
|
||||||
path(
|
path(
|
||||||
"settings/tasks/",
|
"settings/tasks/",
|
||||||
tasks.TaskSettings.as_view(),
|
tasks.TaskSettings.as_view(),
|
||||||
name="tasks_settings",
|
name="tasks_settings",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"settings/codex/",
|
||||||
|
tasks.CodexSettingsPage.as_view(),
|
||||||
|
name="codex_settings",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"settings/codex/approval/",
|
||||||
|
tasks.CodexApprovalAction.as_view(),
|
||||||
|
name="codex_approval",
|
||||||
|
),
|
||||||
path(
|
path(
|
||||||
"settings/availability/",
|
"settings/availability/",
|
||||||
availability.AvailabilitySettingsPage.as_view(),
|
availability.AvailabilitySettingsPage.as_view(),
|
||||||
|
|||||||
@@ -1,18 +1,20 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from asgiref.sync import sync_to_async
|
from asgiref.sync import sync_to_async
|
||||||
from django.conf import settings
|
|
||||||
|
|
||||||
from core.commands.base import CommandContext, CommandResult
|
from core.commands.base import CommandContext, CommandResult
|
||||||
from core.commands.handlers.bp import (
|
from core.commands.handlers.bp import (
|
||||||
BPCommandHandler,
|
BPCommandHandler,
|
||||||
bp_reply_is_optional_for_trigger,
|
bp_reply_is_optional_for_trigger,
|
||||||
|
bp_subcommands_enabled,
|
||||||
bp_trigger_matches,
|
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 get as get_handler
|
||||||
from core.commands.registry import register
|
from core.commands.registry import register
|
||||||
from core.messaging.reply_sync import is_mirrored_origin
|
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
|
from core.util import logs
|
||||||
|
|
||||||
log = logs.get_logger("command_engine")
|
log = logs.get_logger("command_engine")
|
||||||
@@ -36,17 +38,178 @@ def _channel_variants(service: str, channel_identifier: str) -> list[str]:
|
|||||||
return variants
|
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():
|
def ensure_handlers_registered():
|
||||||
global _REGISTERED
|
global _REGISTERED
|
||||||
if _REGISTERED:
|
if _REGISTERED:
|
||||||
return
|
return
|
||||||
register(BPCommandHandler())
|
register(BPCommandHandler())
|
||||||
|
register(CodexCommandHandler())
|
||||||
_REGISTERED = True
|
_REGISTERED = True
|
||||||
|
|
||||||
|
|
||||||
async def _eligible_profiles(ctx: CommandContext) -> list[CommandProfile]:
|
async def _eligible_profiles(ctx: CommandContext) -> list[CommandProfile]:
|
||||||
def _load():
|
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)
|
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:
|
if not direct_variants:
|
||||||
return []
|
return []
|
||||||
direct = list(
|
direct = list(
|
||||||
@@ -65,15 +228,13 @@ async def _eligible_profiles(ctx: CommandContext) -> list[CommandProfile]:
|
|||||||
# underlying conversation is mapped to a platform identifier.
|
# underlying conversation is mapped to a platform identifier.
|
||||||
if str(ctx.service or "").strip().lower() != "web":
|
if str(ctx.service or "").strip().lower() != "web":
|
||||||
return []
|
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)
|
identifier = getattr(getattr(trigger, "session", None), "identifier", None)
|
||||||
fallback_service = str(getattr(identifier, "service", "") or "").strip().lower()
|
fallback_service = str(getattr(identifier, "service", "") or "").strip().lower()
|
||||||
fallback_identifier = str(getattr(identifier, "identifier", "") or "").strip()
|
fallback_identifier = str(getattr(identifier, "identifier", "") or "").strip()
|
||||||
fallback_variants = _channel_variants(fallback_service, fallback_identifier)
|
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:
|
if not fallback_service or not fallback_variants:
|
||||||
return []
|
return []
|
||||||
return list(
|
return list(
|
||||||
@@ -91,12 +252,18 @@ async def _eligible_profiles(ctx: CommandContext) -> list[CommandProfile]:
|
|||||||
|
|
||||||
|
|
||||||
def _matches_trigger(profile: CommandProfile, text: str) -> bool:
|
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(
|
return bp_trigger_matches(
|
||||||
message_text=text,
|
message_text=text,
|
||||||
trigger_token=profile.trigger_token,
|
trigger_token=profile.trigger_token,
|
||||||
exact_match_only=profile.exact_match_only,
|
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()
|
body = str(text or "").strip()
|
||||||
trigger = str(profile.trigger_token or "").strip()
|
trigger = str(profile.trigger_token or "").strip()
|
||||||
if not trigger:
|
if not trigger:
|
||||||
@@ -115,6 +282,10 @@ async def process_inbound_message(ctx: CommandContext) -> list[CommandResult]:
|
|||||||
return []
|
return []
|
||||||
if is_mirrored_origin(trigger_message.message_meta):
|
if is_mirrored_origin(trigger_message.message_meta):
|
||||||
return []
|
return []
|
||||||
|
await sync_to_async(_auto_setup_profile_bindings_for_first_command)(
|
||||||
|
ctx,
|
||||||
|
trigger_message,
|
||||||
|
)
|
||||||
|
|
||||||
profiles = await _eligible_profiles(ctx)
|
profiles = await _eligible_profiles(ctx)
|
||||||
results: list[CommandResult] = []
|
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.reply_required and trigger_message.reply_to_id is None:
|
||||||
if (
|
if (
|
||||||
profile.slug == "bp"
|
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)
|
and bp_reply_is_optional_for_trigger(ctx.message_text)
|
||||||
):
|
):
|
||||||
pass
|
pass
|
||||||
|
|||||||
@@ -23,8 +23,15 @@ from core.models import (
|
|||||||
Message,
|
Message,
|
||||||
)
|
)
|
||||||
|
|
||||||
_BP_SET_RE = re.compile(r"^\s*#bp\s+set#(?P<rest>.*)$", re.IGNORECASE | re.DOTALL)
|
_BP_ROOT_RE = re.compile(r"^\s*(?:\.bp\b|#bp#?)\s*$", re.IGNORECASE)
|
||||||
_BP_SET_RANGE_RE = re.compile(r"^\s*#bp\s+set\s+range#(?:.*)$", re.IGNORECASE | re.DOTALL)
|
_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):
|
class BPParsedCommand(dict):
|
||||||
@@ -49,17 +56,26 @@ def parse_bp_subcommand(text: str) -> BPParsedCommand:
|
|||||||
return BPParsedCommand(command=None, remainder_text="")
|
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:
|
def bp_trigger_matches(message_text: str, trigger_token: str, exact_match_only: bool) -> bool:
|
||||||
body = str(message_text or "").strip()
|
body = str(message_text or "").strip()
|
||||||
trigger = str(trigger_token or "").strip()
|
trigger = str(trigger_token or "").strip()
|
||||||
parsed = parse_bp_subcommand(body)
|
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
|
return True
|
||||||
if not trigger:
|
if not trigger:
|
||||||
return False
|
return False
|
||||||
if exact_match_only:
|
if exact_match_only:
|
||||||
return body == trigger
|
return body.lower() == trigger.lower()
|
||||||
return trigger in body
|
return trigger.lower() in body.lower()
|
||||||
|
|
||||||
|
|
||||||
def bp_reply_is_optional_for_trigger(message_text: str) -> bool:
|
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"),
|
"generation_mode": str(policy.generation_mode or "verbatim"),
|
||||||
"send_plan_to_egress": bool(policy.send_plan_to_egress)
|
"send_plan_to_egress": bool(policy.send_plan_to_egress)
|
||||||
and ("post_result" in action_types),
|
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),
|
"send_status_to_egress": bool(policy.send_status_to_egress),
|
||||||
"store_document": bool(getattr(policy, "store_document", True)),
|
"store_document": bool(getattr(policy, "store_document", True)),
|
||||||
}
|
}
|
||||||
@@ -614,7 +631,7 @@ class BPCommandHandler(CommandHandler):
|
|||||||
return CommandResult(ok=False, status="skipped", error=run.error)
|
return CommandResult(ok=False, status="skipped", error=run.error)
|
||||||
|
|
||||||
parsed = parse_bp_subcommand(ctx.message_text)
|
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(
|
return await self._execute_set_or_range(
|
||||||
trigger=trigger,
|
trigger=trigger,
|
||||||
run=run,
|
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_VARIANT_META = {
|
||||||
"bp": {
|
"bp": {
|
||||||
"name": "bp",
|
"name": "bp",
|
||||||
"trigger_token": "#bp#",
|
"trigger_token": ".bp",
|
||||||
"template_supported": True,
|
"template_supported": True,
|
||||||
"position": 0,
|
"position": 0,
|
||||||
},
|
},
|
||||||
"bp_set": {
|
"bp_set": {
|
||||||
"name": "bp set",
|
"name": "bp set",
|
||||||
"trigger_token": "#bp set#",
|
"trigger_token": ".bp set",
|
||||||
"template_supported": False,
|
"template_supported": False,
|
||||||
"position": 1,
|
"position": 1,
|
||||||
},
|
},
|
||||||
"bp_set_range": {
|
"bp_set_range": {
|
||||||
"name": "bp set range",
|
"name": "bp set range",
|
||||||
"trigger_token": "#bp set range#",
|
"trigger_token": ".bp set range",
|
||||||
"template_supported": False,
|
"template_supported": False,
|
||||||
"position": 2,
|
"position": 2,
|
||||||
},
|
},
|
||||||
@@ -63,6 +63,9 @@ def ensure_variant_policies_for_profile(
|
|||||||
result: dict[str, CommandVariantPolicy] = {}
|
result: dict[str, CommandVariantPolicy] = {}
|
||||||
|
|
||||||
if str(profile.slug or "").strip() == "bp":
|
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:
|
for key in BP_VARIANT_KEYS:
|
||||||
meta = BP_VARIANT_META.get(key, {})
|
meta = BP_VARIANT_META.get(key, {})
|
||||||
defaults = _bp_defaults(profile, key, post_result_enabled)
|
defaults = _bp_defaults(profile, key, post_result_enabled)
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import time
|
import time
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from asgiref.sync import async_to_sync
|
||||||
from django.core.management.base import BaseCommand
|
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.tasks.providers import get_provider
|
||||||
from core.util import logs
|
from core.util import logs
|
||||||
|
|
||||||
@@ -58,11 +61,36 @@ class Command(BaseCommand):
|
|||||||
event.status = "failed"
|
event.status = "failed"
|
||||||
event.error = "provider_disabled_or_missing"
|
event.error = "provider_disabled_or_missing"
|
||||||
event.save(update_fields=["status", "error", "updated_at"])
|
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
|
return
|
||||||
|
|
||||||
payload = dict(event.payload or {})
|
payload = dict(event.payload or {})
|
||||||
action = str(payload.get("action") or "append_update").strip().lower()
|
action = str(payload.get("action") or "append_update").strip().lower()
|
||||||
provider_payload = dict(payload.get("provider_payload") or payload)
|
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":
|
if action == "create":
|
||||||
result = provider.create_task(dict(cfg.settings or {}), provider_payload)
|
result = provider.create_task(dict(cfg.settings or {}), provider_payload)
|
||||||
@@ -73,14 +101,106 @@ class Command(BaseCommand):
|
|||||||
else:
|
else:
|
||||||
result = provider.append_update(dict(cfg.settings or {}), provider_payload)
|
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.status = "ok" if result.ok else "failed"
|
||||||
event.error = str(result.error or "")
|
event.error = str(result.error or "")
|
||||||
event.payload = dict(
|
event.payload = dict(
|
||||||
payload,
|
payload,
|
||||||
worker_processed=True,
|
worker_processed=True,
|
||||||
result=dict(result.payload or {}),
|
result=result_payload,
|
||||||
)
|
)
|
||||||
event.save(update_fields=["status", "error", "payload", "updated_at"])
|
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():
|
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)
|
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"),
|
("ok", "OK"),
|
||||||
("failed", "Failed"),
|
("failed", "Failed"),
|
||||||
("retrying", "Retrying"),
|
("retrying", "Retrying"),
|
||||||
|
("waiting_approval", "Waiting Approval"),
|
||||||
)
|
)
|
||||||
|
|
||||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
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):
|
class ContactAvailabilitySettings(models.Model):
|
||||||
user = models.OneToOneField(
|
user = models.OneToOneField(
|
||||||
User,
|
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 asgiref.sync import sync_to_async
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.db.models import Q
|
|
||||||
|
|
||||||
from core.clients.transport import send_message_raw
|
from core.clients.transport import send_message_raw
|
||||||
from core.messaging import ai as ai_runner
|
from core.messaging import ai as ai_runner
|
||||||
@@ -12,21 +11,36 @@ from core.models import (
|
|||||||
AI,
|
AI,
|
||||||
Chat,
|
Chat,
|
||||||
ChatTaskSource,
|
ChatTaskSource,
|
||||||
|
CodexRun,
|
||||||
DerivedTask,
|
DerivedTask,
|
||||||
DerivedTaskEvent,
|
DerivedTaskEvent,
|
||||||
ExternalSyncEvent,
|
ExternalSyncEvent,
|
||||||
ExternalChatLink,
|
|
||||||
Message,
|
Message,
|
||||||
PersonIdentifier,
|
|
||||||
TaskCompletionPattern,
|
TaskCompletionPattern,
|
||||||
|
TaskEpic,
|
||||||
TaskProviderConfig,
|
TaskProviderConfig,
|
||||||
)
|
)
|
||||||
from core.tasks.providers import get_provider
|
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)
|
_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)
|
_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)
|
_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)
|
_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]:
|
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]:
|
async def _resolve_source_mappings(message: Message) -> list[ChatTaskSource]:
|
||||||
variants = _channel_variants(message.source_service or "", message.source_chat_id or "")
|
lookup_service = str(message.source_service or "").strip().lower()
|
||||||
if str(message.source_service or "").strip().lower() == "signal":
|
variants = _channel_variants(lookup_service, message.source_chat_id or "")
|
||||||
signal_value = str(message.source_chat_id or "").strip()
|
session_identifier = getattr(getattr(message, "session", None), "identifier", None)
|
||||||
if signal_value:
|
canonical_service = str(getattr(session_identifier, "service", "") or "").strip().lower()
|
||||||
companions = await sync_to_async(list)(
|
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)
|
Chat.objects.filter(source_uuid=signal_value).values_list("source_number", flat=True)
|
||||||
)
|
)
|
||||||
companions += await sync_to_async(list)(
|
companions += await sync_to_async(list)(
|
||||||
Chat.objects.filter(source_number=signal_value).values_list("source_uuid", flat=True)
|
Chat.objects.filter(source_number=signal_value).values_list("source_uuid", flat=True)
|
||||||
)
|
)
|
||||||
for candidate in companions:
|
for candidate in companions:
|
||||||
for expanded in _channel_variants("signal", str(candidate or "").strip()):
|
for expanded in _channel_variants("signal", str(candidate or "").strip()):
|
||||||
if expanded and expanded not in variants:
|
if expanded and expanded not in variants:
|
||||||
variants.append(expanded)
|
variants.append(expanded)
|
||||||
if not variants:
|
if not variants:
|
||||||
return []
|
return []
|
||||||
return await sync_to_async(list)(
|
return await sync_to_async(list)(
|
||||||
ChatTaskSource.objects.filter(
|
ChatTaskSource.objects.filter(
|
||||||
user=message.user,
|
user=message.user,
|
||||||
enabled=True,
|
enabled=True,
|
||||||
service=message.source_service,
|
service=lookup_service,
|
||||||
channel_identifier__in=variants,
|
channel_identifier__in=variants,
|
||||||
).select_related("project", "epic")
|
).select_related("project", "epic")
|
||||||
)
|
)
|
||||||
@@ -107,6 +138,58 @@ def _parse_prefixes(raw) -> list[str]:
|
|||||||
return rows or ["task:", "todo:", "action:"]
|
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:
|
def _normalize_flags(raw: dict | None) -> dict:
|
||||||
row = dict(raw or {})
|
row = dict(raw or {})
|
||||||
return {
|
return {
|
||||||
@@ -157,7 +240,7 @@ def _is_task_candidate(text: str, flags: dict) -> bool:
|
|||||||
return False
|
return False
|
||||||
body_lower = body.lower()
|
body_lower = body.lower()
|
||||||
prefixes = list(flags.get("allowed_prefixes") or [])
|
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:
|
if bool(flags.get("require_prefix")) and not has_prefix:
|
||||||
return False
|
return False
|
||||||
mode = str(flags.get("match_mode") or "balanced").strip().lower()
|
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:
|
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)):
|
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 (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:
|
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_settings = dict(getattr(cfg, "settings", {}) or {})
|
||||||
provider = get_provider(provider_name)
|
provider = get_provider(provider_name)
|
||||||
idempotency_key = f"{provider_name}:{task.id}:{event.id}"
|
idempotency_key = f"{provider_name}:{task.id}:{event.id}"
|
||||||
variants = _channel_variants(task.source_service or "", task.source_channel or "")
|
external_chat_id = await sync_to_async(resolve_external_chat_id)(
|
||||||
person_identifier = None
|
user=task.user,
|
||||||
if variants:
|
provider=provider_name,
|
||||||
person_identifier = await sync_to_async(
|
service=str(task.source_service or ""),
|
||||||
lambda: PersonIdentifier.objects.filter(
|
channel=str(task.source_channel or ""),
|
||||||
user=task.user,
|
)
|
||||||
service=task.source_service,
|
cached_project = task._state.fields_cache.get("project")
|
||||||
identifier__in=variants,
|
cached_epic = task._state.fields_cache.get("epic")
|
||||||
)
|
project_name = str(getattr(cached_project, "name", "") or "")
|
||||||
.select_related("person")
|
epic_name = str(getattr(cached_epic, "name", "") or "")
|
||||||
.order_by("-id")
|
request_payload = {
|
||||||
.first()
|
"task_id": str(task.id),
|
||||||
)()
|
"reference_code": str(task.reference_code or ""),
|
||||||
external_chat_id = ""
|
"title": str(task.title or ""),
|
||||||
if person_identifier is not None:
|
"external_key": str(task.external_key or ""),
|
||||||
link = await sync_to_async(
|
"project_name": project_name,
|
||||||
lambda: ExternalChatLink.objects.filter(
|
"epic_name": epic_name,
|
||||||
user=task.user,
|
"source_service": str(task.source_service or ""),
|
||||||
provider=provider_name,
|
"source_channel": str(task.source_channel or ""),
|
||||||
enabled=True,
|
"external_chat_id": external_chat_id,
|
||||||
)
|
"origin_message_id": str(getattr(task, "origin_message_id", "") or ""),
|
||||||
.filter(
|
"trigger_message_id": str(getattr(event, "source_message_id", "") or getattr(task, "origin_message_id", "") or ""),
|
||||||
Q(person_identifier=person_identifier)
|
"mode": "default",
|
||||||
| Q(person=person_identifier.person)
|
"payload": event.payload,
|
||||||
)
|
}
|
||||||
.order_by("-updated_at", "-id")
|
codex_run = await sync_to_async(CodexRun.objects.create)(
|
||||||
.first()
|
user=task.user,
|
||||||
)()
|
task_id=task.id,
|
||||||
if link is not None:
|
derived_task_event_id=event.id,
|
||||||
external_chat_id = str(link.external_chat_id or "").strip()
|
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`.
|
# Worker-backed providers are queued and executed by `manage.py codex_worker`.
|
||||||
if bool(getattr(provider, "run_in_worker", False)):
|
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",
|
"status": "pending",
|
||||||
"payload": {
|
"payload": {
|
||||||
"action": action,
|
"action": action,
|
||||||
"provider_payload": {
|
"provider_payload": dict(request_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,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
"error": "",
|
"error": "",
|
||||||
},
|
},
|
||||||
@@ -281,34 +373,11 @@ async def _emit_sync_event(task: DerivedTask, event: DerivedTaskEvent, action: s
|
|||||||
return
|
return
|
||||||
|
|
||||||
if action == "create":
|
if action == "create":
|
||||||
result = provider.create_task(provider_settings, {
|
result = provider.create_task(provider_settings, dict(request_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,
|
|
||||||
})
|
|
||||||
elif action == "complete":
|
elif action == "complete":
|
||||||
result = provider.mark_complete(provider_settings, {
|
result = provider.mark_complete(provider_settings, dict(request_payload))
|
||||||
"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,
|
|
||||||
})
|
|
||||||
else:
|
else:
|
||||||
result = provider.append_update(provider_settings, {
|
result = provider.append_update(provider_settings, dict(request_payload))
|
||||||
"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,
|
|
||||||
})
|
|
||||||
|
|
||||||
status = "ok" if result.ok else "failed"
|
status = "ok" if result.ok else "failed"
|
||||||
await sync_to_async(ExternalSyncEvent.objects.update_or_create)(
|
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 ""),
|
"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:
|
if result.ok and result.external_key and not task.external_key:
|
||||||
task.external_key = str(result.external_key)
|
task.external_key = str(result.external_key)
|
||||||
await sync_to_async(task.save)(update_fields=["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)
|
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:
|
async def process_inbound_task_intelligence(message: Message) -> None:
|
||||||
if message is None:
|
if message is None:
|
||||||
return
|
return
|
||||||
@@ -350,6 +538,10 @@ async def process_inbound_task_intelligence(message: Message) -> None:
|
|||||||
sources = await _resolve_source_mappings(message)
|
sources = await _resolve_source_mappings(message)
|
||||||
if not sources:
|
if not sources:
|
||||||
return
|
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_allowed = any(bool(_effective_flags(source).get("completion_enabled")) for source in sources)
|
||||||
completion_rx = await _completion_regex(message) if completion_allowed else None
|
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)
|
flags = _effective_flags(source)
|
||||||
if not bool(flags.get("derive_enabled", True)):
|
if not bool(flags.get("derive_enabled", True)):
|
||||||
continue
|
continue
|
||||||
if not _is_task_candidate(text, flags):
|
task_text = _strip_epic_token(text)
|
||||||
|
if not _is_task_candidate(task_text, flags):
|
||||||
continue
|
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)
|
reference = await sync_to_async(_next_reference)(message.user, source.project)
|
||||||
task = await sync_to_async(DerivedTask.objects.create)(
|
task = await sync_to_async(DerivedTask.objects.create)(
|
||||||
user=message.user,
|
user=message.user,
|
||||||
project=source.project,
|
project=source.project,
|
||||||
epic=source.epic,
|
epic=epic,
|
||||||
title=title,
|
title=title,
|
||||||
source_service=message.source_service or "web",
|
source_service=source.service or message.source_service or "web",
|
||||||
source_channel=message.source_chat_id or "",
|
source_channel=source.channel_identifier or message.source_chat_id or "",
|
||||||
origin_message=message,
|
origin_message=message,
|
||||||
reference_code=reference,
|
reference_code=reference,
|
||||||
status_snapshot="open",
|
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)(
|
event = await sync_to_async(DerivedTaskEvent.objects.create)(
|
||||||
task=task,
|
task=task,
|
||||||
@@ -426,8 +634,8 @@ async def process_inbound_task_intelligence(message: Message) -> None:
|
|||||||
if bool(flags.get("announce_task_id", False)):
|
if bool(flags.get("announce_task_id", False)):
|
||||||
try:
|
try:
|
||||||
await send_message_raw(
|
await send_message_raw(
|
||||||
message.source_service or "web",
|
source.service or message.source_service or "web",
|
||||||
message.source_chat_id or "",
|
source.channel_identifier or message.source_chat_id or "",
|
||||||
text=f"[task] Created #{task.reference_code}: {task.title}",
|
text=f"[task] Created #{task.reference_code}: {task.title}",
|
||||||
attachments=[],
|
attachments=[],
|
||||||
metadata={"origin": "task_announce"},
|
metadata={"origin": "task_announce"},
|
||||||
@@ -435,3 +643,22 @@ async def process_inbound_task_intelligence(message: Message) -> None:
|
|||||||
except Exception:
|
except Exception:
|
||||||
# Announcement is best-effort and should not block derivation.
|
# Announcement is best-effort and should not block derivation.
|
||||||
pass
|
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:
|
except Exception:
|
||||||
parsed = {"raw_stdout": stdout}
|
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 = (
|
ext = (
|
||||||
str(parsed.get("external_key") or "").strip()
|
str(parsed.get("external_key") or "").strip()
|
||||||
or str(parsed.get("task_id") or "").strip()
|
or str(parsed.get("task_id") or "").strip()
|
||||||
@@ -78,6 +86,8 @@ class CodexCLITaskProvider(TaskProvider):
|
|||||||
"returncode": int(completed.returncode),
|
"returncode": int(completed.returncode),
|
||||||
"stdout": stdout[:4000],
|
"stdout": stdout[:4000],
|
||||||
"stderr": stderr[:4000],
|
"stderr": stderr[:4000],
|
||||||
|
"parsed_status": parsed_status,
|
||||||
|
"requires_approval": requires_approval,
|
||||||
}
|
}
|
||||||
out_payload.update(parsed)
|
out_payload.update(parsed)
|
||||||
return ProviderResult(ok=ok, external_key=ext, error=("" if ok else stderr[:4000]), payload=out_payload)
|
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>
|
||||||
<div class="column is-4">
|
<div class="column is-4">
|
||||||
<label class="label is-size-7" for="create_trigger_token">Primary Trigger Token</label>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<label class="label is-size-7" for="create_template_text">BP Template (used only by <code>bp</code> in AI mode)</label>
|
<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;
|
border-top: 1px solid #dbdbdb;
|
||||||
}
|
}
|
||||||
</style>
|
</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 %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -9,7 +9,15 @@
|
|||||||
· Source message <code>{{ task.origin_message_id }}</code>
|
· Source message <code>{{ task.origin_message_id }}</code>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</p>
|
</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">
|
<article class="box">
|
||||||
<h2 class="title is-6">Events</h2>
|
<h2 class="title is-6">Events</h2>
|
||||||
<table class="table is-fullwidth is-size-7">
|
<table class="table is-fullwidth is-size-7">
|
||||||
@@ -58,6 +66,44 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</article>
|
</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>
|
</div></section>
|
||||||
<style>
|
<style>
|
||||||
.task-event-payload {
|
.task-event-payload {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<h1 class="title is-4">Tasks</h1>
|
<h1 class="title is-4">Tasks</h1>
|
||||||
<p class="subtitle is-6">Immutable tasks derived from chat activity.</p>
|
<p class="subtitle is-6">Immutable tasks derived from chat activity.</p>
|
||||||
<div class="buttons" style="margin-bottom: 0.75rem;">
|
<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>
|
||||||
<div class="columns is-variable is-5">
|
<div class="columns is-variable is-5">
|
||||||
<div class="column is-4">
|
<div class="column is-4">
|
||||||
@@ -134,7 +134,7 @@
|
|||||||
<article class="box">
|
<article class="box">
|
||||||
<h2 class="title is-6">Recent Derived Tasks</h2>
|
<h2 class="title is-6">Recent Derived Tasks</h2>
|
||||||
<table class="table is-fullwidth is-striped is-size-7">
|
<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>
|
<tbody>
|
||||||
{% for row in tasks %}
|
{% for row in tasks %}
|
||||||
<tr>
|
<tr>
|
||||||
@@ -148,7 +148,15 @@
|
|||||||
</td>
|
</td>
|
||||||
<td>{{ row.project.name }}{% if row.epic %} / {{ row.epic.name }}{% endif %}</td>
|
<td>{{ row.project.name }}{% if row.epic %} / {{ row.epic.name }}{% endif %}</td>
|
||||||
<td>{{ row.status_snapshot }}</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>
|
</tr>
|
||||||
{% empty %}
|
{% empty %}
|
||||||
<tr><td colspan="6">No derived tasks yet.</td></tr>
|
<tr><td colspan="6">No derived tasks yet.</td></tr>
|
||||||
|
|||||||
@@ -63,11 +63,26 @@
|
|||||||
<td>{{ row.title }}</td>
|
<td>{{ row.title }}</td>
|
||||||
<td>
|
<td>
|
||||||
{{ row.creator_label|default:"Unknown" }}
|
{{ row.creator_label|default:"Unknown" }}
|
||||||
{% if row.creator_identifier %}
|
{% if row.creator_compose_href %}
|
||||||
<div class="has-text-grey"><code>{{ row.creator_identifier }}</code></div>
|
<div><a class="is-size-7" href="{{ row.creator_compose_href }}">Compose</a></div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</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>
|
<td><a class="button is-small is-light" href="{% url 'tasks_task' task_id=row.id %}">Open</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
{% empty %}
|
{% empty %}
|
||||||
|
|||||||
@@ -335,7 +335,7 @@
|
|||||||
<input type="hidden" name="provider" value="codex_cli">
|
<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>
|
<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">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;">
|
<div class="field" style="margin-top:0.5rem;">
|
||||||
<label class="label is-size-7">Command</label>
|
<label class="label is-size-7">Command</label>
|
||||||
<input class="input is-small" name="command" value="{{ codex_provider_settings.command }}" placeholder="codex">
|
<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>
|
<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 }}">
|
<input class="input is-small" type="number" min="1" name="timeout_seconds" value="{{ codex_provider_settings.timeout_seconds }}">
|
||||||
</div>
|
</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;">
|
<div style="margin-top:0.5rem;">
|
||||||
<button class="button is-small is-link is-light" type="submit">Save Codex Provider</button>
|
<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>
|
</div>
|
||||||
</form>
|
</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>
|
<p class="help">Browse all derived tasks in <a href="{% url 'tasks_hub' %}">Tasks Hub</a>.</p>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -396,6 +396,8 @@
|
|||||||
data-summary-url="{{ compose_summary_url }}"
|
data-summary-url="{{ compose_summary_url }}"
|
||||||
data-quick-insights-url="{{ compose_quick_insights_url }}"
|
data-quick-insights-url="{{ compose_quick_insights_url }}"
|
||||||
data-history-sync-url="{{ compose_history_sync_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-toggle-command-url="{{ compose_toggle_command_url }}"
|
||||||
data-engage-preview-url="{{ compose_engage_preview_url }}"
|
data-engage-preview-url="{{ compose_engage_preview_url }}"
|
||||||
data-engage-send-url="{{ compose_engage_send_url }}">
|
data-engage-send-url="{{ compose_engage_send_url }}">
|
||||||
@@ -458,11 +460,33 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
<p class="compose-body compose-image-fallback is-hidden">(no text)</p>
|
<p class="compose-body compose-image-fallback is-hidden">(no text)</p>
|
||||||
{% endif %}
|
{% 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 %}
|
{% if msg.reactions %}
|
||||||
<div class="compose-reactions" aria-label="Message reactions">
|
<div class="compose-reactions" aria-label="Message reactions">
|
||||||
{% for reaction in msg.reactions %}
|
{% for reaction in msg.reactions %}
|
||||||
<span
|
<span
|
||||||
class="compose-reaction-chip"
|
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 }}">
|
title="{{ reaction.actor|default:'Unknown' }} via {{ reaction.source_service|default:'unknown'|upper }}">
|
||||||
{{ reaction.emoji }}
|
{{ reaction.emoji }}
|
||||||
</span>
|
</span>
|
||||||
@@ -935,6 +959,61 @@
|
|||||||
gap: 0.26rem;
|
gap: 0.26rem;
|
||||||
margin: 0 0 0.28rem 0;
|
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 {
|
#{{ panel_id }} .compose-reaction-chip {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -2767,6 +2846,138 @@
|
|||||||
thread.insertBefore(row, rows[0]);
|
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 appendBubble = function (msg) {
|
||||||
const messageId = String(msg && msg.id ? msg.id : "").trim();
|
const messageId = String(msg && msg.id ? msg.id : "").trim();
|
||||||
if (messageId) {
|
if (messageId) {
|
||||||
@@ -2867,22 +3078,14 @@
|
|||||||
fallback.textContent = "(no text)";
|
fallback.textContent = "(no text)";
|
||||||
bubble.appendChild(fallback);
|
bubble.appendChild(fallback);
|
||||||
}
|
}
|
||||||
if (Array.isArray(msg.reactions) && msg.reactions.length) {
|
const reactionBar = buildReactionActions(messageId);
|
||||||
const reactionsWrap = document.createElement("div");
|
if (reactionBar) {
|
||||||
reactionsWrap.className = "compose-reactions";
|
bubble.appendChild(reactionBar);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
renderBubbleReactions(
|
||||||
|
bubble,
|
||||||
|
Array.isArray(msg.reactions) ? msg.reactions : []
|
||||||
|
);
|
||||||
|
|
||||||
const meta = document.createElement("p");
|
const meta = document.createElement("p");
|
||||||
meta.className = "compose-msg-meta";
|
meta.className = "compose-msg-meta";
|
||||||
@@ -3019,6 +3222,88 @@
|
|||||||
|
|
||||||
// Delegate click on tick triggers inside thread
|
// Delegate click on tick triggers inside thread
|
||||||
thread.addEventListener("click", function (ev) {
|
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");
|
const replyBtn = ev.target.closest && ev.target.closest(".compose-reply-btn");
|
||||||
if (replyBtn) {
|
if (replyBtn) {
|
||||||
const row = replyBtn.closest(".compose-row");
|
const row = replyBtn.closest(".compose-row");
|
||||||
@@ -3060,6 +3345,11 @@
|
|||||||
|
|
||||||
// Close receipt popover on outside click / escape
|
// Close receipt popover on outside click / escape
|
||||||
document.addEventListener("click", function (ev) {
|
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.classList.contains('is-hidden')) return;
|
||||||
if (receiptPopover.contains(ev.target)) return;
|
if (receiptPopover.contains(ev.target)) return;
|
||||||
if (activeReceiptBtn && activeReceiptBtn.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")
|
parsed = parse_bp_subcommand("#bp set range# now")
|
||||||
self.assertEqual("set_range", parsed.command)
|
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):
|
def test_set_standalone_uses_remainder_only(self):
|
||||||
trigger = Message.objects.create(
|
trigger = Message.objects.create(
|
||||||
user=self.user,
|
user=self.user,
|
||||||
|
|||||||
@@ -58,3 +58,16 @@ class CodexCLITaskProviderTests(SimpleTestCase):
|
|||||||
result = self.provider.append_update({"command": "codex", "timeout_seconds": 10}, {"task_id": "t1"})
|
result = self.provider.append_update({"command": "codex", "timeout_seconds": 10}, {"task_id": "t1"})
|
||||||
self.assertFalse(result.ok)
|
self.assertFalse(result.ok)
|
||||||
self.assertIn("timeout", result.error)
|
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, "Variant Policies")
|
||||||
self.assertContains(response, "bp set range")
|
self.assertContains(response, "bp set range")
|
||||||
self.assertContains(response, "Send status to egress")
|
self.assertContains(response, "Send status to egress")
|
||||||
|
self.assertContains(response, "Codex (codex)")
|
||||||
|
|
||||||
def test_variant_policy_update_persists(self):
|
def test_variant_policy_update_persists(self):
|
||||||
response = self.client.post(
|
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.views.compose import _command_options_for_channel
|
||||||
from core.models import (
|
from core.models import (
|
||||||
ChatSession,
|
ChatSession,
|
||||||
|
CommandAction,
|
||||||
CommandChannelBinding,
|
CommandChannelBinding,
|
||||||
CommandProfile,
|
CommandProfile,
|
||||||
|
CommandVariantPolicy,
|
||||||
Message,
|
Message,
|
||||||
Person,
|
Person,
|
||||||
PersonIdentifier,
|
PersonIdentifier,
|
||||||
@@ -313,4 +315,124 @@ class Phase1CommandEngineTests(TestCase):
|
|||||||
self.assertIn("bp", names)
|
self.assertIn("bp", names)
|
||||||
self.assertIn("bp set", names)
|
self.assertIn("bp set", names)
|
||||||
self.assertIn("bp set range", names)
|
self.assertIn("bp set range", names)
|
||||||
|
self.assertIn("codex", names)
|
||||||
self.assertNotIn("announce task ids", 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 __future__ import annotations
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
from asgiref.sync import async_to_sync
|
from asgiref.sync import async_to_sync
|
||||||
from django.test import TestCase, override_settings
|
from django.test import TestCase, override_settings
|
||||||
|
|
||||||
@@ -13,6 +15,7 @@ from core.models import (
|
|||||||
Person,
|
Person,
|
||||||
PersonIdentifier,
|
PersonIdentifier,
|
||||||
TaskCompletionPattern,
|
TaskCompletionPattern,
|
||||||
|
TaskEpic,
|
||||||
TaskProject,
|
TaskProject,
|
||||||
User,
|
User,
|
||||||
Message,
|
Message,
|
||||||
@@ -197,3 +200,208 @@ class TaskEngineTests(TestCase):
|
|||||||
DerivedTask.objects.filter(origin_message=m).exists(),
|
DerivedTask.objects.filter(origin_message=m).exists(),
|
||||||
"Expected Signal UUID source chat to match source mapping by companion number.",
|
"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 __future__ import annotations
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
@@ -73,6 +75,16 @@ class TasksPagesManagementTests(TestCase):
|
|||||||
).exists()
|
).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):
|
def test_project_page_can_create_and_delete_epic(self):
|
||||||
project = TaskProject.objects.create(user=self.user, name="Roadmap")
|
project = TaskProject.objects.create(user=self.user, name="Roadmap")
|
||||||
create_response = self.client.post(
|
create_response = self.client.post(
|
||||||
@@ -98,6 +110,81 @@ class TasksPagesManagementTests(TestCase):
|
|||||||
self.assertEqual(200, delete_response.status_code)
|
self.assertEqual(200, delete_response.status_code)
|
||||||
self.assertFalse(TaskEpic.objects.filter(project=project, name="Phase 1").exists())
|
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):
|
def test_group_page_create_and_map_project(self):
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
reverse(
|
reverse(
|
||||||
@@ -156,6 +243,36 @@ class TasksPagesManagementTests(TestCase):
|
|||||||
self.assertEqual(200, response.status_code)
|
self.assertEqual(200, response.status_code)
|
||||||
self.assertContains(response, "Scope Person")
|
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):
|
def test_task_detail_renders_payload_summary_and_json(self):
|
||||||
project = TaskProject.objects.create(user=self.user, name="Payload Test")
|
project = TaskProject.objects.create(user=self.user, name="Payload Test")
|
||||||
session = ChatSession.objects.create(user=self.user, identifier=self.pid_signal)
|
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 (
|
from core.models import (
|
||||||
ChatSession,
|
ChatSession,
|
||||||
ChatTaskSource,
|
ChatTaskSource,
|
||||||
|
CodexPermissionRequest,
|
||||||
|
CodexRun,
|
||||||
DerivedTask,
|
DerivedTask,
|
||||||
|
ExternalSyncEvent,
|
||||||
ExternalChatLink,
|
ExternalChatLink,
|
||||||
Message,
|
Message,
|
||||||
Person,
|
Person,
|
||||||
PersonIdentifier,
|
PersonIdentifier,
|
||||||
TaskCompletionPattern,
|
TaskCompletionPattern,
|
||||||
|
TaskProviderConfig,
|
||||||
TaskProject,
|
TaskProject,
|
||||||
User,
|
User,
|
||||||
)
|
)
|
||||||
@@ -118,6 +122,7 @@ class TaskAnnounceToggleTests(TestCase):
|
|||||||
self.assertIn("bp", names)
|
self.assertIn("bp", names)
|
||||||
self.assertIn("bp set", names)
|
self.assertIn("bp set", names)
|
||||||
self.assertIn("bp set range", names)
|
self.assertIn("bp set range", names)
|
||||||
|
self.assertIn("codex", names)
|
||||||
|
|
||||||
|
|
||||||
@override_settings(TASK_DERIVATION_USE_AI=False)
|
@override_settings(TASK_DERIVATION_USE_AI=False)
|
||||||
@@ -268,3 +273,97 @@ class TaskSettingsExternalChatLinkScopeTests(TestCase):
|
|||||||
external_chat_id="codex-chat-abc",
|
external_chat_id="codex-chat-abc",
|
||||||
).exists()
|
).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"),
|
"channel_services": ("web", "xmpp", "signal", "whatsapp"),
|
||||||
"directions": ("ingress", "egress", "scratchpad_mirror"),
|
"directions": ("ingress", "egress", "scratchpad_mirror"),
|
||||||
"action_types": ("extract_bp", "post_result", "save_document"),
|
"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_service": scope_service,
|
||||||
"scope_identifier": scope_identifier,
|
"scope_identifier": scope_identifier,
|
||||||
"scope_variants": scope_variants,
|
"scope_variants": scope_variants,
|
||||||
@@ -153,40 +156,55 @@ class CommandRoutingSettings(LoginRequiredMixin, View):
|
|||||||
user=request.user,
|
user=request.user,
|
||||||
slug=slug,
|
slug=slug,
|
||||||
defaults={
|
defaults={
|
||||||
"name": str(request.POST.get("name") or "Business Plan").strip()
|
"name": str(request.POST.get("name") or ("Codex" if slug == "codex" else "Business Plan")).strip()
|
||||||
or "Business Plan",
|
or ("Codex" if slug == "codex" else "Business Plan"),
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
"trigger_token": str(
|
"trigger_token": str(
|
||||||
request.POST.get("trigger_token") or "#bp#"
|
request.POST.get("trigger_token")
|
||||||
|
or (".codex" if slug == "codex" else ".bp")
|
||||||
).strip()
|
).strip()
|
||||||
or "#bp#",
|
or (".codex" if slug == "codex" else ".bp"),
|
||||||
"template_text": str(request.POST.get("template_text") or ""),
|
"template_text": str(request.POST.get("template_text") or ""),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
profile.name = str(request.POST.get("name") or profile.name).strip() or profile.name
|
profile.name = str(request.POST.get("name") or profile.name).strip() or profile.name
|
||||||
if slug == "bp":
|
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.template_text = str(request.POST.get("template_text") or profile.template_text or "")
|
||||||
profile.save(update_fields=["name", "trigger_token", "template_text", "updated_at"])
|
if slug == "codex":
|
||||||
CommandAction.objects.get_or_create(
|
profile.trigger_token = ".codex"
|
||||||
profile=profile,
|
profile.reply_required = False
|
||||||
action_type="extract_bp",
|
profile.exact_match_only = False
|
||||||
defaults={"enabled": True, "position": 0},
|
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
|
if slug == "bp":
|
||||||
# potential reuse by non-bp commands; bp UI now relies on
|
CommandAction.objects.get_or_create(
|
||||||
# variant policies instead of exposing the generic action matrix.
|
profile=profile,
|
||||||
CommandAction.objects.get_or_create(
|
action_type="extract_bp",
|
||||||
profile=profile,
|
defaults={"enabled": True, "position": 0},
|
||||||
action_type="save_document",
|
)
|
||||||
defaults={"enabled": True, "position": 1},
|
# Keep legacy action rows in storage for compatibility and for
|
||||||
)
|
# potential reuse by non-bp commands; bp UI now relies on
|
||||||
CommandAction.objects.get_or_create(
|
# variant policies instead of exposing the generic action matrix.
|
||||||
profile=profile,
|
CommandAction.objects.get_or_create(
|
||||||
action_type="post_result",
|
profile=profile,
|
||||||
defaults={"enabled": True, "position": 2},
|
action_type="save_document",
|
||||||
)
|
defaults={"enabled": True, "position": 1},
|
||||||
ensure_variant_policies_for_profile(profile)
|
)
|
||||||
|
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)
|
return self._redirect_with_scope(request)
|
||||||
|
|
||||||
if action == "profile_update":
|
if action == "profile_update":
|
||||||
@@ -199,7 +217,7 @@ class CommandRoutingSettings(LoginRequiredMixin, View):
|
|||||||
profile.enabled = bool(request.POST.get("enabled"))
|
profile.enabled = bool(request.POST.get("enabled"))
|
||||||
profile.trigger_token = (
|
profile.trigger_token = (
|
||||||
str(request.POST.get("trigger_token") or profile.trigger_token).strip()
|
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.reply_required = bool(request.POST.get("reply_required"))
|
||||||
profile.exact_match_only = bool(request.POST.get("exact_match_only"))
|
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 django.views import View
|
||||||
|
|
||||||
from core.clients import transport
|
from core.clients import transport
|
||||||
|
from core.assist.engine import process_inbound_assist
|
||||||
from core.commands.base import CommandContext
|
from core.commands.base import CommandContext
|
||||||
from core.commands.engine import process_inbound_message
|
from core.commands.engine import process_inbound_message
|
||||||
from core.commands.policies import ensure_variant_policies_for_profile
|
from core.commands.policies import ensure_variant_policies_for_profile
|
||||||
from core.messaging import ai as ai_runner
|
from core.messaging import ai as ai_runner
|
||||||
|
from core.messaging import history
|
||||||
from core.messaging import media_bridge
|
from core.messaging import media_bridge
|
||||||
from core.messaging.utils import messages_to_string
|
from core.messaging.utils import messages_to_string
|
||||||
from core.models import (
|
from core.models import (
|
||||||
@@ -1792,6 +1794,86 @@ def _build_signal_reply_metadata(reply_to: Message | None, channel_identifier: s
|
|||||||
return payload
|
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:
|
def _canonical_command_channel_identifier(service: str, identifier: str) -> str:
|
||||||
value = str(identifier or "").strip()
|
value = str(identifier or "").strip()
|
||||||
if not value:
|
if not value:
|
||||||
@@ -1818,7 +1900,7 @@ def _ensure_bp_profile_and_actions(user) -> CommandProfile:
|
|||||||
defaults={
|
defaults={
|
||||||
"name": "Business Plan",
|
"name": "Business Plan",
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
"trigger_token": "#bp#",
|
"trigger_token": ".bp",
|
||||||
"reply_required": True,
|
"reply_required": True,
|
||||||
"exact_match_only": True,
|
"exact_match_only": True,
|
||||||
"window_scope": "conversation",
|
"window_scope": "conversation",
|
||||||
@@ -1828,6 +1910,9 @@ def _ensure_bp_profile_and_actions(user) -> CommandProfile:
|
|||||||
if not profile.enabled:
|
if not profile.enabled:
|
||||||
profile.enabled = True
|
profile.enabled = True
|
||||||
profile.save(update_fields=["enabled", "updated_at"])
|
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 (
|
for action_type, position in (
|
||||||
("extract_bp", 0),
|
("extract_bp", 0),
|
||||||
("save_document", 1),
|
("save_document", 1),
|
||||||
@@ -1845,6 +1930,29 @@ def _ensure_bp_profile_and_actions(user) -> CommandProfile:
|
|||||||
return profile
|
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(
|
def _toggle_command_for_channel(
|
||||||
*,
|
*,
|
||||||
user,
|
user,
|
||||||
@@ -1860,6 +1968,8 @@ def _toggle_command_for_channel(
|
|||||||
|
|
||||||
if slug == "bp":
|
if slug == "bp":
|
||||||
profile = _ensure_bp_profile_and_actions(user)
|
profile = _ensure_bp_profile_and_actions(user)
|
||||||
|
elif slug == "codex":
|
||||||
|
profile = _ensure_codex_profile(user)
|
||||||
else:
|
else:
|
||||||
profile = (
|
profile = (
|
||||||
CommandProfile.objects.filter(user=user, slug=slug)
|
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,
|
user=user,
|
||||||
slug="bp",
|
slug="bp",
|
||||||
name="Business Plan",
|
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,
|
enabled=True,
|
||||||
)
|
)
|
||||||
slugs = sorted(by_slug.keys())
|
slugs = sorted(by_slug.keys())
|
||||||
@@ -1945,7 +2063,7 @@ def _command_options_for_channel(user, service: str, identifier: str) -> list[di
|
|||||||
"slug": "bp",
|
"slug": "bp",
|
||||||
"toggle_slug": "bp",
|
"toggle_slug": "bp",
|
||||||
"name": "bp",
|
"name": "bp",
|
||||||
"trigger_token": "#bp#",
|
"trigger_token": ".bp",
|
||||||
"enabled_here": bool(enabled_here),
|
"enabled_here": bool(enabled_here),
|
||||||
"profile_enabled": bool(profile.enabled),
|
"profile_enabled": bool(profile.enabled),
|
||||||
"mode_label": str(
|
"mode_label": str(
|
||||||
@@ -1956,7 +2074,7 @@ def _command_options_for_channel(user, service: str, identifier: str) -> list[di
|
|||||||
"slug": "bp_set",
|
"slug": "bp_set",
|
||||||
"toggle_slug": "bp",
|
"toggle_slug": "bp",
|
||||||
"name": "bp set",
|
"name": "bp set",
|
||||||
"trigger_token": "#bp set#",
|
"trigger_token": ".bp set",
|
||||||
"enabled_here": bool(enabled_here),
|
"enabled_here": bool(enabled_here),
|
||||||
"profile_enabled": bool(profile.enabled),
|
"profile_enabled": bool(profile.enabled),
|
||||||
"mode_label": str(
|
"mode_label": str(
|
||||||
@@ -1967,7 +2085,7 @@ def _command_options_for_channel(user, service: str, identifier: str) -> list[di
|
|||||||
"slug": "bp_set_range",
|
"slug": "bp_set_range",
|
||||||
"toggle_slug": "bp",
|
"toggle_slug": "bp",
|
||||||
"name": "bp set range",
|
"name": "bp set range",
|
||||||
"trigger_token": "#bp set range#",
|
"trigger_token": ".bp set range",
|
||||||
"enabled_here": bool(enabled_here),
|
"enabled_here": bool(enabled_here),
|
||||||
"profile_enabled": bool(profile.enabled),
|
"profile_enabled": bool(profile.enabled),
|
||||||
"mode_label": str(
|
"mode_label": str(
|
||||||
@@ -4484,6 +4602,7 @@ class ComposeSend(LoginRequiredMixin, View):
|
|||||||
payload={},
|
payload={},
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
async_to_sync(process_inbound_assist)(created_message)
|
||||||
async_to_sync(process_inbound_translation)(created_message)
|
async_to_sync(process_inbound_translation)(created_message)
|
||||||
# Notify XMPP clients from runtime so cross-platform sends appear there too.
|
# Notify XMPP clients from runtime so cross-platform sends appear there too.
|
||||||
if base["service"] in {"signal", "whatsapp"}:
|
if base["service"] in {"signal", "whatsapp"}:
|
||||||
@@ -4516,3 +4635,116 @@ class ComposeSend(LoginRequiredMixin, View):
|
|||||||
level="success",
|
level="success",
|
||||||
panel_id=panel_id,
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import hashlib
|
||||||
from urllib.parse import urlencode
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
from asgiref.sync import async_to_sync
|
from asgiref.sync import async_to_sync
|
||||||
@@ -11,12 +12,15 @@ from django.db.models import Count
|
|||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.http import JsonResponse
|
from django.http import JsonResponse
|
||||||
from django.shortcuts import get_object_or_404, redirect, render
|
from django.shortcuts import get_object_or_404, redirect, render
|
||||||
|
from django.utils import timezone
|
||||||
from django.views import View
|
from django.views import View
|
||||||
|
|
||||||
from core.clients.transport import send_message_raw
|
from core.clients.transport import send_message_raw
|
||||||
from core.models import (
|
from core.models import (
|
||||||
AnswerSuggestionEvent,
|
AnswerSuggestionEvent,
|
||||||
ChatTaskSource,
|
ChatTaskSource,
|
||||||
|
CodexPermissionRequest,
|
||||||
|
CodexRun,
|
||||||
DerivedTask,
|
DerivedTask,
|
||||||
DerivedTaskEvent,
|
DerivedTaskEvent,
|
||||||
ExternalSyncEvent,
|
ExternalSyncEvent,
|
||||||
@@ -30,6 +34,7 @@ from core.models import (
|
|||||||
Chat,
|
Chat,
|
||||||
ExternalChatLink,
|
ExternalChatLink,
|
||||||
)
|
)
|
||||||
|
from core.tasks.codex_support import resolve_external_chat_id
|
||||||
from core.tasks.providers import get_provider
|
from core.tasks.providers import get_provider
|
||||||
|
|
||||||
SAFE_TASK_FLAGS_DEFAULTS = {
|
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):
|
def _apply_task_creator_labels(user, task_rows):
|
||||||
rows = list(task_rows or [])
|
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:
|
for row in rows:
|
||||||
origin = getattr(row, "origin_message", None)
|
origin = getattr(row, "origin_message", None)
|
||||||
service_key = str(getattr(row, "source_service", "") or "").strip().lower()
|
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_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
|
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:
|
def _normalize_channel_identifier(service: str, identifier: str) -> str:
|
||||||
service_key = str(service or "").strip().lower()
|
service_key = str(service or "").strip().lower()
|
||||||
value = str(identifier or "").strip()
|
value = str(identifier or "").strip()
|
||||||
@@ -337,6 +468,41 @@ def _upsert_group_source(*, user, service: str, channel_identifier: str, project
|
|||||||
return source
|
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]:
|
def _person_identifier_scope_variants(service: str, identifier: str) -> list[str]:
|
||||||
service_key = str(service or "").strip().lower()
|
service_key = str(service or "").strip().lower()
|
||||||
raw_identifier = str(identifier or "").strip()
|
raw_identifier = str(identifier or "").strip()
|
||||||
@@ -690,6 +856,7 @@ class TaskProjectDetail(LoginRequiredMixin, View):
|
|||||||
return redirect("tasks_project", project_id=str(project.id))
|
return redirect("tasks_project", project_id=str(project.id))
|
||||||
if created:
|
if created:
|
||||||
messages.success(request, f"Created epic '{epic.name}'.")
|
messages.success(request, f"Created epic '{epic.name}'.")
|
||||||
|
_notify_epic_created_in_project_chats(project=project, epic=epic)
|
||||||
else:
|
else:
|
||||||
messages.info(request, f"Epic '{epic.name}' already exists.")
|
messages.info(request, f"Epic '{epic.name}' already exists.")
|
||||||
return redirect("tasks_project", project_id=str(project.id))
|
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}'.")
|
messages.success(request, f"Deleted epic '{deleted_name}'.")
|
||||||
return redirect("tasks_project", project_id=str(project.id))
|
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":
|
if action == "project_delete":
|
||||||
deleted_name = str(project.name or "").strip() or "Project"
|
deleted_name = str(project.name or "").strip() or "Project"
|
||||||
project.delete()
|
project.delete()
|
||||||
@@ -862,6 +1051,10 @@ class TaskDetail(LoginRequiredMixin, View):
|
|||||||
getattr(task, "origin_message", None),
|
getattr(task, "origin_message", None),
|
||||||
)
|
)
|
||||||
sync_events = task.external_sync_events.order_by("-created_at")
|
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(
|
return render(
|
||||||
request,
|
request,
|
||||||
self.template_name,
|
self.template_name,
|
||||||
@@ -869,6 +1062,8 @@ class TaskDetail(LoginRequiredMixin, View):
|
|||||||
"task": task,
|
"task": task,
|
||||||
"events": events,
|
"events": events,
|
||||||
"sync_events": sync_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"])
|
row.allowed_prefixes_csv = ",".join(row.settings_effective["allowed_prefixes"])
|
||||||
provider_map = _provider_row_map(request.user)
|
provider_map = _provider_row_map(request.user)
|
||||||
codex_cfg = provider_map.get("codex_cli")
|
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")
|
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(
|
external_chat_links = list(
|
||||||
ExternalChatLink.objects.filter(user=request.user).select_related(
|
ExternalChatLink.objects.filter(user=request.user).select_related(
|
||||||
"person", "person_identifier"
|
"person", "person_identifier"
|
||||||
@@ -932,6 +1158,19 @@ class TaskSettings(LoginRequiredMixin, View):
|
|||||||
"default_profile": str(codex_settings.get("default_profile") or ""),
|
"default_profile": str(codex_settings.get("default_profile") or ""),
|
||||||
"timeout_seconds": int(codex_settings.get("timeout_seconds") or 60),
|
"timeout_seconds": int(codex_settings.get("timeout_seconds") or 60),
|
||||||
"chat_link_mode": str(codex_settings.get("chat_link_mode") or "task-sync"),
|
"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,
|
"person_identifiers": person_identifiers,
|
||||||
"external_link_person_identifiers": external_link_person_identifiers,
|
"external_link_person_identifiers": external_link_person_identifiers,
|
||||||
@@ -961,12 +1200,13 @@ class TaskSettings(LoginRequiredMixin, View):
|
|||||||
|
|
||||||
if action == "epic_create":
|
if action == "epic_create":
|
||||||
project = get_object_or_404(TaskProject, id=request.POST.get("project_id"), user=request.user)
|
project = get_object_or_404(TaskProject, id=request.POST.get("project_id"), user=request.user)
|
||||||
TaskEpic.objects.create(
|
epic = TaskEpic.objects.create(
|
||||||
project=project,
|
project=project,
|
||||||
name=str(request.POST.get("name") or "Epic").strip() or "Epic",
|
name=str(request.POST.get("name") or "Epic").strip() or "Epic",
|
||||||
external_key=str(request.POST.get("external_key") or "").strip(),
|
external_key=str(request.POST.get("external_key") or "").strip(),
|
||||||
active=bool(request.POST.get("active") or "1"),
|
active=bool(request.POST.get("active") or "1"),
|
||||||
)
|
)
|
||||||
|
_notify_epic_created_in_project_chats(project=project, epic=epic)
|
||||||
return _settings_redirect(request)
|
return _settings_redirect(request)
|
||||||
|
|
||||||
if action == "source_create":
|
if action == "source_create":
|
||||||
@@ -1063,18 +1303,18 @@ class TaskSettings(LoginRequiredMixin, View):
|
|||||||
row.enabled = bool(request.POST.get("enabled"))
|
row.enabled = bool(request.POST.get("enabled"))
|
||||||
settings_payload = dict(row.settings or {})
|
settings_payload = dict(row.settings or {})
|
||||||
if provider == "codex_cli":
|
if provider == "codex_cli":
|
||||||
timeout_raw = str(request.POST.get("timeout_seconds") or "60").strip()
|
settings_payload = _codex_settings_with_defaults(
|
||||||
try:
|
{
|
||||||
timeout_value = max(1, int(timeout_raw))
|
"command": request.POST.get("command"),
|
||||||
except Exception:
|
"workspace_root": request.POST.get("workspace_root"),
|
||||||
timeout_value = 60
|
"default_profile": request.POST.get("default_profile"),
|
||||||
settings_payload = {
|
"timeout_seconds": request.POST.get("timeout_seconds"),
|
||||||
"command": str(request.POST.get("command") or "codex").strip() or "codex",
|
"instance_label": request.POST.get("instance_label"),
|
||||||
"workspace_root": str(request.POST.get("workspace_root") or "").strip(),
|
"approver_service": request.POST.get("approver_service"),
|
||||||
"default_profile": str(request.POST.get("default_profile") or "").strip(),
|
"approver_identifier": request.POST.get("approver_identifier"),
|
||||||
"timeout_seconds": timeout_value,
|
"approver_mode": "channel",
|
||||||
"chat_link_mode": "task-sync",
|
}
|
||||||
}
|
)
|
||||||
row.settings = settings_payload
|
row.settings = settings_payload
|
||||||
row.save(update_fields=["enabled", "settings", "updated_at"])
|
row.save(update_fields=["enabled", "settings", "updated_at"])
|
||||||
return _settings_redirect(request)
|
return _settings_redirect(request)
|
||||||
@@ -1159,6 +1399,196 @@ class TaskSettings(LoginRequiredMixin, View):
|
|||||||
return _settings_redirect(request)
|
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):
|
class AnswerSuggestionSend(LoginRequiredMixin, View):
|
||||||
def post(self, request):
|
def post(self, request):
|
||||||
event = get_object_or_404(
|
event = get_object_or_404(
|
||||||
|
|||||||
Reference in New Issue
Block a user