Implement plans

This commit is contained in:
2026-03-04 02:19:22 +00:00
parent 34ee49410d
commit 0718a06c19
31 changed files with 3987 additions and 181 deletions

View File

@@ -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(),

View File

@@ -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

View File

@@ -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,

View 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,
)

View File

@@ -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)

View File

@@ -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)

View 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"),
],
},
),
]

View File

@@ -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,

View 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() + "..."

View File

@@ -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

View File

@@ -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)

View 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 %}

View File

@@ -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 %}

View File

@@ -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 {

View File

@@ -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>

View File

@@ -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 %}

View File

@@ -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>

View File

@@ -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;

View File

@@ -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,

View File

@@ -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"))

View 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()
)

View 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))

View File

@@ -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(

View 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"),
)

View File

@@ -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(),
)

View File

@@ -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 ""))

View File

@@ -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&amp;identifier=%2B15551230000&amp;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)

View File

@@ -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)

View File

@@ -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"))

View File

@@ -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 []),
}
)

View File

@@ -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(