Implement plans
This commit is contained in:
@@ -129,7 +129,10 @@ class CommandRoutingSettings(LoginRequiredMixin, View):
|
||||
"channel_services": ("web", "xmpp", "signal", "whatsapp"),
|
||||
"directions": ("ingress", "egress", "scratchpad_mirror"),
|
||||
"action_types": ("extract_bp", "post_result", "save_document"),
|
||||
"command_choices": (("bp", "Business Plan (bp)"),),
|
||||
"command_choices": (
|
||||
("bp", "Business Plan (bp)"),
|
||||
("codex", "Codex (codex)"),
|
||||
),
|
||||
"scope_service": scope_service,
|
||||
"scope_identifier": scope_identifier,
|
||||
"scope_variants": scope_variants,
|
||||
@@ -153,40 +156,55 @@ class CommandRoutingSettings(LoginRequiredMixin, View):
|
||||
user=request.user,
|
||||
slug=slug,
|
||||
defaults={
|
||||
"name": str(request.POST.get("name") or "Business Plan").strip()
|
||||
or "Business Plan",
|
||||
"name": str(request.POST.get("name") or ("Codex" if slug == "codex" else "Business Plan")).strip()
|
||||
or ("Codex" if slug == "codex" else "Business Plan"),
|
||||
"enabled": True,
|
||||
"trigger_token": str(
|
||||
request.POST.get("trigger_token") or "#bp#"
|
||||
request.POST.get("trigger_token")
|
||||
or (".codex" if slug == "codex" else ".bp")
|
||||
).strip()
|
||||
or "#bp#",
|
||||
or (".codex" if slug == "codex" else ".bp"),
|
||||
"template_text": str(request.POST.get("template_text") or ""),
|
||||
},
|
||||
)
|
||||
profile.name = str(request.POST.get("name") or profile.name).strip() or profile.name
|
||||
if slug == "bp":
|
||||
profile.trigger_token = "#bp#"
|
||||
profile.trigger_token = ".bp"
|
||||
profile.template_text = str(request.POST.get("template_text") or profile.template_text or "")
|
||||
profile.save(update_fields=["name", "trigger_token", "template_text", "updated_at"])
|
||||
CommandAction.objects.get_or_create(
|
||||
profile=profile,
|
||||
action_type="extract_bp",
|
||||
defaults={"enabled": True, "position": 0},
|
||||
if slug == "codex":
|
||||
profile.trigger_token = ".codex"
|
||||
profile.reply_required = False
|
||||
profile.exact_match_only = False
|
||||
profile.save(
|
||||
update_fields=[
|
||||
"name",
|
||||
"trigger_token",
|
||||
"template_text",
|
||||
"reply_required",
|
||||
"exact_match_only",
|
||||
"updated_at",
|
||||
]
|
||||
)
|
||||
# Keep legacy action rows in storage for compatibility and for
|
||||
# potential reuse by non-bp commands; bp UI now relies on
|
||||
# variant policies instead of exposing the generic action matrix.
|
||||
CommandAction.objects.get_or_create(
|
||||
profile=profile,
|
||||
action_type="save_document",
|
||||
defaults={"enabled": True, "position": 1},
|
||||
)
|
||||
CommandAction.objects.get_or_create(
|
||||
profile=profile,
|
||||
action_type="post_result",
|
||||
defaults={"enabled": True, "position": 2},
|
||||
)
|
||||
ensure_variant_policies_for_profile(profile)
|
||||
if slug == "bp":
|
||||
CommandAction.objects.get_or_create(
|
||||
profile=profile,
|
||||
action_type="extract_bp",
|
||||
defaults={"enabled": True, "position": 0},
|
||||
)
|
||||
# Keep legacy action rows in storage for compatibility and for
|
||||
# potential reuse by non-bp commands; bp UI now relies on
|
||||
# variant policies instead of exposing the generic action matrix.
|
||||
CommandAction.objects.get_or_create(
|
||||
profile=profile,
|
||||
action_type="save_document",
|
||||
defaults={"enabled": True, "position": 1},
|
||||
)
|
||||
CommandAction.objects.get_or_create(
|
||||
profile=profile,
|
||||
action_type="post_result",
|
||||
defaults={"enabled": True, "position": 2},
|
||||
)
|
||||
ensure_variant_policies_for_profile(profile)
|
||||
return self._redirect_with_scope(request)
|
||||
|
||||
if action == "profile_update":
|
||||
@@ -199,7 +217,7 @@ class CommandRoutingSettings(LoginRequiredMixin, View):
|
||||
profile.enabled = bool(request.POST.get("enabled"))
|
||||
profile.trigger_token = (
|
||||
str(request.POST.get("trigger_token") or profile.trigger_token).strip()
|
||||
or "#bp#"
|
||||
or ".bp"
|
||||
)
|
||||
profile.reply_required = bool(request.POST.get("reply_required"))
|
||||
profile.exact_match_only = bool(request.POST.get("exact_match_only"))
|
||||
|
||||
@@ -26,10 +26,12 @@ from django.utils import timezone as dj_timezone
|
||||
from django.views import View
|
||||
|
||||
from core.clients import transport
|
||||
from core.assist.engine import process_inbound_assist
|
||||
from core.commands.base import CommandContext
|
||||
from core.commands.engine import process_inbound_message
|
||||
from core.commands.policies import ensure_variant_policies_for_profile
|
||||
from core.messaging import ai as ai_runner
|
||||
from core.messaging import history
|
||||
from core.messaging import media_bridge
|
||||
from core.messaging.utils import messages_to_string
|
||||
from core.models import (
|
||||
@@ -1792,6 +1794,86 @@ def _build_signal_reply_metadata(reply_to: Message | None, channel_identifier: s
|
||||
return payload
|
||||
|
||||
|
||||
def _parse_bool(value, default: bool = False) -> bool:
|
||||
if value is None:
|
||||
return bool(default)
|
||||
raw = str(value).strip().lower()
|
||||
if raw in {"1", "true", "yes", "on"}:
|
||||
return True
|
||||
if raw in {"0", "false", "no", "off"}:
|
||||
return False
|
||||
return bool(default)
|
||||
|
||||
|
||||
def _reaction_actor_key(user_id, service: str) -> str:
|
||||
return f"web:{int(user_id)}:{str(service or '').strip().lower()}"
|
||||
|
||||
|
||||
def _resolve_reaction_target(message: Message, service: str, channel_identifier: str) -> dict:
|
||||
service_key = _default_service(service)
|
||||
source_message_id = str(getattr(message, "source_message_id", "") or "").strip()
|
||||
sender_uuid = str(getattr(message, "sender_uuid", "") or "").strip()
|
||||
source_chat_id = str(getattr(message, "source_chat_id", "") or "").strip()
|
||||
delivered_ts = int(getattr(message, "delivered_ts", 0) or 0)
|
||||
local_ts = int(getattr(message, "ts", 0) or 0)
|
||||
|
||||
if service_key == "signal":
|
||||
target_ts = 0
|
||||
if source_message_id.isdigit():
|
||||
target_ts = int(source_message_id)
|
||||
if not target_ts:
|
||||
bridge_ref = _latest_signal_bridge_ref(message)
|
||||
upstream_id = str(bridge_ref.get("upstream_message_id") or "").strip()
|
||||
if upstream_id.isdigit():
|
||||
target_ts = int(upstream_id)
|
||||
if not target_ts:
|
||||
target_ts = int(bridge_ref.get("upstream_ts") or 0)
|
||||
if not target_ts:
|
||||
target_ts = delivered_ts or local_ts
|
||||
if target_ts <= 0:
|
||||
return {"error": "signal_target_unresolvable"}
|
||||
|
||||
target_author = sender_uuid
|
||||
if not target_author:
|
||||
bridge_ref = _latest_signal_bridge_ref(message)
|
||||
target_author = str(bridge_ref.get("upstream_author") or "").strip()
|
||||
if (
|
||||
str(getattr(message, "custom_author", "") or "").strip().upper()
|
||||
in {"USER", "BOT"}
|
||||
):
|
||||
target_author = (
|
||||
str(getattr(settings, "SIGNAL_NUMBER", "") or "").strip() or target_author
|
||||
)
|
||||
if not target_author:
|
||||
target_author = source_chat_id or str(channel_identifier or "").strip()
|
||||
if not target_author:
|
||||
return {"error": "signal_target_author_unresolvable"}
|
||||
|
||||
return {
|
||||
"target_message_id": "",
|
||||
"target_ts": int(target_ts),
|
||||
"target_author": target_author,
|
||||
}
|
||||
|
||||
if service_key == "whatsapp":
|
||||
target_message_id = source_message_id
|
||||
target_ts = delivered_ts or local_ts
|
||||
if not target_message_id:
|
||||
bridge_ref = _latest_whatsapp_bridge_ref(message)
|
||||
target_message_id = str(bridge_ref.get("upstream_message_id") or "").strip()
|
||||
if not target_ts:
|
||||
target_ts = int(bridge_ref.get("upstream_ts") or 0)
|
||||
if not target_message_id:
|
||||
return {"error": "whatsapp_target_unresolvable"}
|
||||
return {
|
||||
"target_message_id": target_message_id,
|
||||
"target_ts": int(target_ts or 0),
|
||||
"target_author": "",
|
||||
}
|
||||
|
||||
return {"error": "service_not_supported"}
|
||||
|
||||
|
||||
def _canonical_command_channel_identifier(service: str, identifier: str) -> str:
|
||||
value = str(identifier or "").strip()
|
||||
if not value:
|
||||
@@ -1818,7 +1900,7 @@ def _ensure_bp_profile_and_actions(user) -> CommandProfile:
|
||||
defaults={
|
||||
"name": "Business Plan",
|
||||
"enabled": True,
|
||||
"trigger_token": "#bp#",
|
||||
"trigger_token": ".bp",
|
||||
"reply_required": True,
|
||||
"exact_match_only": True,
|
||||
"window_scope": "conversation",
|
||||
@@ -1828,6 +1910,9 @@ def _ensure_bp_profile_and_actions(user) -> CommandProfile:
|
||||
if not profile.enabled:
|
||||
profile.enabled = True
|
||||
profile.save(update_fields=["enabled", "updated_at"])
|
||||
if str(profile.trigger_token or "").strip() != ".bp":
|
||||
profile.trigger_token = ".bp"
|
||||
profile.save(update_fields=["trigger_token", "updated_at"])
|
||||
for action_type, position in (
|
||||
("extract_bp", 0),
|
||||
("save_document", 1),
|
||||
@@ -1845,6 +1930,29 @@ def _ensure_bp_profile_and_actions(user) -> CommandProfile:
|
||||
return profile
|
||||
|
||||
|
||||
def _ensure_codex_profile(user) -> CommandProfile:
|
||||
profile, _ = CommandProfile.objects.get_or_create(
|
||||
user=user,
|
||||
slug="codex",
|
||||
defaults={
|
||||
"name": "Codex",
|
||||
"enabled": True,
|
||||
"trigger_token": ".codex",
|
||||
"reply_required": False,
|
||||
"exact_match_only": False,
|
||||
"window_scope": "conversation",
|
||||
"visibility_mode": "status_in_source",
|
||||
},
|
||||
)
|
||||
if not profile.enabled:
|
||||
profile.enabled = True
|
||||
profile.save(update_fields=["enabled", "updated_at"])
|
||||
if str(profile.trigger_token or "").strip() != ".codex":
|
||||
profile.trigger_token = ".codex"
|
||||
profile.save(update_fields=["trigger_token", "updated_at"])
|
||||
return profile
|
||||
|
||||
|
||||
def _toggle_command_for_channel(
|
||||
*,
|
||||
user,
|
||||
@@ -1860,6 +1968,8 @@ def _toggle_command_for_channel(
|
||||
|
||||
if slug == "bp":
|
||||
profile = _ensure_bp_profile_and_actions(user)
|
||||
elif slug == "codex":
|
||||
profile = _ensure_codex_profile(user)
|
||||
else:
|
||||
profile = (
|
||||
CommandProfile.objects.filter(user=user, slug=slug)
|
||||
@@ -1916,7 +2026,15 @@ def _command_options_for_channel(user, service: str, identifier: str) -> list[di
|
||||
user=user,
|
||||
slug="bp",
|
||||
name="Business Plan",
|
||||
trigger_token="#bp#",
|
||||
trigger_token=".bp",
|
||||
enabled=True,
|
||||
)
|
||||
if "codex" not in by_slug:
|
||||
by_slug["codex"] = CommandProfile(
|
||||
user=user,
|
||||
slug="codex",
|
||||
name="Codex",
|
||||
trigger_token=".codex",
|
||||
enabled=True,
|
||||
)
|
||||
slugs = sorted(by_slug.keys())
|
||||
@@ -1945,7 +2063,7 @@ def _command_options_for_channel(user, service: str, identifier: str) -> list[di
|
||||
"slug": "bp",
|
||||
"toggle_slug": "bp",
|
||||
"name": "bp",
|
||||
"trigger_token": "#bp#",
|
||||
"trigger_token": ".bp",
|
||||
"enabled_here": bool(enabled_here),
|
||||
"profile_enabled": bool(profile.enabled),
|
||||
"mode_label": str(
|
||||
@@ -1956,7 +2074,7 @@ def _command_options_for_channel(user, service: str, identifier: str) -> list[di
|
||||
"slug": "bp_set",
|
||||
"toggle_slug": "bp",
|
||||
"name": "bp set",
|
||||
"trigger_token": "#bp set#",
|
||||
"trigger_token": ".bp set",
|
||||
"enabled_here": bool(enabled_here),
|
||||
"profile_enabled": bool(profile.enabled),
|
||||
"mode_label": str(
|
||||
@@ -1967,7 +2085,7 @@ def _command_options_for_channel(user, service: str, identifier: str) -> list[di
|
||||
"slug": "bp_set_range",
|
||||
"toggle_slug": "bp",
|
||||
"name": "bp set range",
|
||||
"trigger_token": "#bp set range#",
|
||||
"trigger_token": ".bp set range",
|
||||
"enabled_here": bool(enabled_here),
|
||||
"profile_enabled": bool(profile.enabled),
|
||||
"mode_label": str(
|
||||
@@ -4484,6 +4602,7 @@ class ComposeSend(LoginRequiredMixin, View):
|
||||
payload={},
|
||||
)
|
||||
)
|
||||
async_to_sync(process_inbound_assist)(created_message)
|
||||
async_to_sync(process_inbound_translation)(created_message)
|
||||
# Notify XMPP clients from runtime so cross-platform sends appear there too.
|
||||
if base["service"] in {"signal", "whatsapp"}:
|
||||
@@ -4516,3 +4635,116 @@ class ComposeSend(LoginRequiredMixin, View):
|
||||
level="success",
|
||||
panel_id=panel_id,
|
||||
)
|
||||
|
||||
|
||||
class ComposeReact(LoginRequiredMixin, View):
|
||||
def post(self, request):
|
||||
service, identifier, person = _request_scope(request, "POST")
|
||||
service_key = _default_service(service)
|
||||
if service_key not in {"signal", "whatsapp"}:
|
||||
return JsonResponse({"ok": False, "error": "service_not_supported"})
|
||||
if not identifier and person is None:
|
||||
return JsonResponse({"ok": False, "error": "missing_scope"})
|
||||
|
||||
message_id = str(request.POST.get("message_id") or "").strip()
|
||||
emoji = str(request.POST.get("emoji") or "").strip()
|
||||
remove_raw = request.POST.get("remove")
|
||||
remove_forced = remove_raw is not None and str(remove_raw).strip() != ""
|
||||
if not message_id:
|
||||
return JsonResponse({"ok": False, "error": "message_id_required"})
|
||||
if not emoji:
|
||||
return JsonResponse({"ok": False, "error": "emoji_required"})
|
||||
|
||||
base = _context_base(request.user, service_key, identifier, person)
|
||||
person_identifier = base.get("person_identifier")
|
||||
if person_identifier is None:
|
||||
return JsonResponse({"ok": False, "error": "message_scope_unresolvable"})
|
||||
|
||||
session = ChatSession.objects.filter(
|
||||
user=request.user,
|
||||
identifier=person_identifier,
|
||||
).first()
|
||||
if session is None:
|
||||
return JsonResponse({"ok": False, "error": "session_not_found"})
|
||||
|
||||
message = Message.objects.filter(
|
||||
user=request.user,
|
||||
session=session,
|
||||
id=message_id,
|
||||
).first()
|
||||
if message is None:
|
||||
return JsonResponse({"ok": False, "error": "message_not_found"})
|
||||
|
||||
target = _resolve_reaction_target(
|
||||
message=message,
|
||||
service=service_key,
|
||||
channel_identifier=str(base.get("identifier") or "").strip(),
|
||||
)
|
||||
if target.get("error"):
|
||||
return JsonResponse({"ok": False, "error": str(target.get("error"))})
|
||||
|
||||
actor_key = _reaction_actor_key(request.user.id, service_key)
|
||||
remove = _parse_bool(remove_raw, default=False)
|
||||
if not remove_forced:
|
||||
existing_rows = list((message.receipt_payload or {}).get("reactions") or [])
|
||||
has_same_reaction = False
|
||||
for row in existing_rows:
|
||||
item = dict(row or {})
|
||||
if bool(item.get("removed")):
|
||||
continue
|
||||
if str(item.get("emoji") or "").strip() != emoji:
|
||||
continue
|
||||
if str(item.get("source_service") or "").strip().lower() != service_key:
|
||||
continue
|
||||
if str(item.get("actor") or "").strip() != actor_key:
|
||||
continue
|
||||
has_same_reaction = True
|
||||
break
|
||||
remove = bool(has_same_reaction)
|
||||
|
||||
target_ts = int(target.get("target_ts") or 0)
|
||||
target_message_id = str(target.get("target_message_id") or "").strip()
|
||||
sent = async_to_sync(transport.send_reaction)(
|
||||
service_key,
|
||||
str(base.get("identifier") or "").strip(),
|
||||
emoji=emoji,
|
||||
target_message_id=target_message_id,
|
||||
target_timestamp=target_ts if target_ts > 0 else None,
|
||||
target_author=str(target.get("target_author") or "").strip(),
|
||||
remove=bool(remove),
|
||||
)
|
||||
if not sent:
|
||||
return JsonResponse({"ok": False, "error": "reaction_send_failed"})
|
||||
|
||||
updated = async_to_sync(history.apply_reaction)(
|
||||
request.user,
|
||||
person_identifier,
|
||||
target_message_id=target_message_id,
|
||||
target_ts=target_ts,
|
||||
emoji=emoji,
|
||||
source_service=service_key,
|
||||
actor=actor_key,
|
||||
remove=bool(remove),
|
||||
payload={
|
||||
"source": "compose_react",
|
||||
"message_id": str(message.id),
|
||||
},
|
||||
)
|
||||
if updated is None:
|
||||
updated = Message.objects.filter(
|
||||
user=request.user,
|
||||
session=session,
|
||||
id=message_id,
|
||||
).first()
|
||||
serialized = _serialize_message(updated or message)
|
||||
return JsonResponse(
|
||||
{
|
||||
"ok": True,
|
||||
"message_id": str(message.id),
|
||||
"emoji": emoji,
|
||||
"remove": bool(remove),
|
||||
"target_upstream_ts": target_ts,
|
||||
"target_upstream_id": target_message_id,
|
||||
"reactions": list(serialized.get("reactions") or []),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import hashlib
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from asgiref.sync import async_to_sync
|
||||
@@ -11,12 +12,15 @@ from django.db.models import Count
|
||||
from django.urls import reverse
|
||||
from django.http import JsonResponse
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.utils import timezone
|
||||
from django.views import View
|
||||
|
||||
from core.clients.transport import send_message_raw
|
||||
from core.models import (
|
||||
AnswerSuggestionEvent,
|
||||
ChatTaskSource,
|
||||
CodexPermissionRequest,
|
||||
CodexRun,
|
||||
DerivedTask,
|
||||
DerivedTaskEvent,
|
||||
ExternalSyncEvent,
|
||||
@@ -30,6 +34,7 @@ from core.models import (
|
||||
Chat,
|
||||
ExternalChatLink,
|
||||
)
|
||||
from core.tasks.codex_support import resolve_external_chat_id
|
||||
from core.tasks.providers import get_provider
|
||||
|
||||
SAFE_TASK_FLAGS_DEFAULTS = {
|
||||
@@ -268,11 +273,47 @@ def _creator_label_for_message(user, service: str, message) -> str:
|
||||
|
||||
def _apply_task_creator_labels(user, task_rows):
|
||||
rows = list(task_rows or [])
|
||||
person_identifier_cache: dict[tuple[str, str], PersonIdentifier | None] = {}
|
||||
|
||||
def _resolve_person_identifier(service_key: str, sender_identifier: str):
|
||||
key = (str(service_key or "").strip().lower(), str(sender_identifier or "").strip())
|
||||
if key in person_identifier_cache:
|
||||
return person_identifier_cache[key]
|
||||
variants = _person_identifier_scope_variants(key[0], key[1])
|
||||
row = (
|
||||
PersonIdentifier.objects.filter(
|
||||
user=user,
|
||||
service=key[0],
|
||||
identifier__in=variants or [key[1]],
|
||||
)
|
||||
.select_related("person")
|
||||
.first()
|
||||
)
|
||||
person_identifier_cache[key] = row
|
||||
return row
|
||||
|
||||
for row in rows:
|
||||
origin = getattr(row, "origin_message", None)
|
||||
service_key = str(getattr(row, "source_service", "") or "").strip().lower()
|
||||
sender_identifier = str(getattr(origin, "sender_uuid", "") or "").strip()
|
||||
row.creator_label = _creator_label_for_message(user, service_key, origin)
|
||||
row.creator_identifier = str(getattr(origin, "sender_uuid", "") or "").strip()
|
||||
row.creator_identifier = sender_identifier
|
||||
row.creator_compose_href = ""
|
||||
if sender_identifier and service_key:
|
||||
person_identifier = _resolve_person_identifier(service_key, sender_identifier)
|
||||
compose_service = service_key
|
||||
compose_identifier = sender_identifier
|
||||
compose_person_id = ""
|
||||
if person_identifier is not None:
|
||||
compose_identifier = str(getattr(person_identifier, "identifier", "") or "").strip() or sender_identifier
|
||||
compose_person_id = str(getattr(person_identifier, "person_id", "") or "")
|
||||
query = {
|
||||
"service": compose_service,
|
||||
"identifier": compose_identifier,
|
||||
}
|
||||
if compose_person_id:
|
||||
query["person"] = compose_person_id
|
||||
row.creator_compose_href = f"{reverse('compose_page')}?{urlencode(query)}"
|
||||
return rows
|
||||
|
||||
|
||||
@@ -283,6 +324,96 @@ def _provider_row_map(user):
|
||||
}
|
||||
|
||||
|
||||
def _codex_settings_with_defaults(raw: dict | None) -> dict:
|
||||
row = dict(raw or {})
|
||||
timeout_raw = str(row.get("timeout_seconds") or "60").strip()
|
||||
try:
|
||||
timeout_seconds = max(1, int(timeout_raw))
|
||||
except Exception:
|
||||
timeout_seconds = 60
|
||||
return {
|
||||
"command": str(row.get("command") or "codex").strip() or "codex",
|
||||
"workspace_root": str(row.get("workspace_root") or "").strip(),
|
||||
"default_profile": str(row.get("default_profile") or "").strip(),
|
||||
"timeout_seconds": timeout_seconds,
|
||||
"chat_link_mode": "task-sync",
|
||||
"instance_label": str(row.get("instance_label") or "default").strip() or "default",
|
||||
"approver_service": str(row.get("approver_service") or "").strip().lower(),
|
||||
"approver_identifier": str(row.get("approver_identifier") or "").strip(),
|
||||
"approver_mode": "channel",
|
||||
}
|
||||
|
||||
|
||||
def _enqueue_codex_task_submission(
|
||||
*,
|
||||
user,
|
||||
task: DerivedTask,
|
||||
source_service: str,
|
||||
source_channel: str,
|
||||
mode: str = "default",
|
||||
command_text: str = "",
|
||||
source_message=None,
|
||||
) -> CodexRun:
|
||||
external_chat_id = resolve_external_chat_id(
|
||||
user=user,
|
||||
provider="codex_cli",
|
||||
service=source_service,
|
||||
channel=source_channel,
|
||||
)
|
||||
provider_payload = {
|
||||
"task_id": str(task.id),
|
||||
"reference_code": str(task.reference_code or ""),
|
||||
"title": str(task.title or ""),
|
||||
"external_key": str(task.external_key or ""),
|
||||
"project_name": str(getattr(task.project, "name", "") or ""),
|
||||
"epic_name": str(getattr(task.epic, "name", "") or ""),
|
||||
"source_service": str(source_service or ""),
|
||||
"source_channel": str(source_channel or ""),
|
||||
"external_chat_id": external_chat_id,
|
||||
"origin_message_id": str(getattr(task, "origin_message_id", "") or ""),
|
||||
"trigger_message_id": str(getattr(source_message, "id", "") or ""),
|
||||
"mode": str(mode or "default"),
|
||||
}
|
||||
if command_text:
|
||||
provider_payload["command_text"] = str(command_text)
|
||||
run = CodexRun.objects.create(
|
||||
user=user,
|
||||
task=task,
|
||||
source_message=source_message,
|
||||
project=task.project,
|
||||
epic=task.epic,
|
||||
source_service=str(source_service or ""),
|
||||
source_channel=str(source_channel or ""),
|
||||
external_chat_id=external_chat_id,
|
||||
status="queued",
|
||||
request_payload={"action": "append_update", "provider_payload": dict(provider_payload)},
|
||||
result_payload={},
|
||||
error="",
|
||||
)
|
||||
provider_payload["codex_run_id"] = str(run.id)
|
||||
run.request_payload = {"action": "append_update", "provider_payload": dict(provider_payload)}
|
||||
run.save(update_fields=["request_payload", "updated_at"])
|
||||
idempotency_key = (
|
||||
f"codex_submit:{task.id}:{mode}:{hashlib.sha1(str(command_text or '').encode('utf-8')).hexdigest()[:10]}:{run.id}"
|
||||
)
|
||||
ExternalSyncEvent.objects.update_or_create(
|
||||
idempotency_key=idempotency_key,
|
||||
defaults={
|
||||
"user": user,
|
||||
"task": task,
|
||||
"task_event": None,
|
||||
"provider": "codex_cli",
|
||||
"status": "pending",
|
||||
"payload": {
|
||||
"action": "append_update",
|
||||
"provider_payload": dict(provider_payload),
|
||||
},
|
||||
"error": "",
|
||||
},
|
||||
)
|
||||
return run
|
||||
|
||||
|
||||
def _normalize_channel_identifier(service: str, identifier: str) -> str:
|
||||
service_key = str(service or "").strip().lower()
|
||||
value = str(identifier or "").strip()
|
||||
@@ -337,6 +468,41 @@ def _upsert_group_source(*, user, service: str, channel_identifier: str, project
|
||||
return source
|
||||
|
||||
|
||||
def _notify_epic_created_in_project_chats(*, project: TaskProject, epic: TaskEpic) -> None:
|
||||
rows = (
|
||||
ChatTaskSource.objects.filter(project=project, enabled=True)
|
||||
.order_by("service", "channel_identifier")
|
||||
.values_list("service", "channel_identifier")
|
||||
)
|
||||
seen: set[tuple[str, str]] = set()
|
||||
for service, channel_identifier in rows:
|
||||
svc = str(service or "").strip().lower()
|
||||
chan = str(channel_identifier or "").strip()
|
||||
if not svc or not chan:
|
||||
continue
|
||||
key = (svc, chan)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
try:
|
||||
async_to_sync(send_message_raw)(
|
||||
svc,
|
||||
chan,
|
||||
text=(
|
||||
f"[epic] Created '{epic.name}' in project '{project.name}'.\n"
|
||||
"WhatsApp usage:\n"
|
||||
"- create epic: epic: <Epic name> (or .epic <Epic name>)\n"
|
||||
"- add task to epic: task: <description> [epic:<Epic name>]\n"
|
||||
"- list tasks: .l list tasks\n"
|
||||
"- undo latest task: .undo"
|
||||
),
|
||||
attachments=[],
|
||||
metadata={"origin": "task_epic_announce"},
|
||||
)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
|
||||
def _person_identifier_scope_variants(service: str, identifier: str) -> list[str]:
|
||||
service_key = str(service or "").strip().lower()
|
||||
raw_identifier = str(identifier or "").strip()
|
||||
@@ -690,6 +856,7 @@ class TaskProjectDetail(LoginRequiredMixin, View):
|
||||
return redirect("tasks_project", project_id=str(project.id))
|
||||
if created:
|
||||
messages.success(request, f"Created epic '{epic.name}'.")
|
||||
_notify_epic_created_in_project_chats(project=project, epic=epic)
|
||||
else:
|
||||
messages.info(request, f"Epic '{epic.name}' already exists.")
|
||||
return redirect("tasks_project", project_id=str(project.id))
|
||||
@@ -701,6 +868,28 @@ class TaskProjectDetail(LoginRequiredMixin, View):
|
||||
messages.success(request, f"Deleted epic '{deleted_name}'.")
|
||||
return redirect("tasks_project", project_id=str(project.id))
|
||||
|
||||
if action == "task_set_epic":
|
||||
task = get_object_or_404(
|
||||
DerivedTask,
|
||||
id=request.POST.get("task_id"),
|
||||
user=request.user,
|
||||
project=project,
|
||||
)
|
||||
epic_id = str(request.POST.get("epic_id") or "").strip()
|
||||
epic = None
|
||||
if epic_id:
|
||||
epic = get_object_or_404(TaskEpic, id=epic_id, project=project)
|
||||
task.epic = epic
|
||||
task.save(update_fields=["epic"])
|
||||
if epic is None:
|
||||
messages.success(request, f"Cleared epic for task #{task.reference_code}.")
|
||||
else:
|
||||
messages.success(
|
||||
request,
|
||||
f"Assigned task #{task.reference_code} to epic '{epic.name}'.",
|
||||
)
|
||||
return redirect("tasks_project", project_id=str(project.id))
|
||||
|
||||
if action == "project_delete":
|
||||
deleted_name = str(project.name or "").strip() or "Project"
|
||||
project.delete()
|
||||
@@ -862,6 +1051,10 @@ class TaskDetail(LoginRequiredMixin, View):
|
||||
getattr(task, "origin_message", None),
|
||||
)
|
||||
sync_events = task.external_sync_events.order_by("-created_at")
|
||||
codex_runs = task.codex_runs.select_related("source_message").order_by("-created_at")
|
||||
permission_requests = CodexPermissionRequest.objects.filter(codex_run__task=task).select_related(
|
||||
"codex_run", "external_sync_event"
|
||||
).order_by("-requested_at")
|
||||
return render(
|
||||
request,
|
||||
self.template_name,
|
||||
@@ -869,6 +1062,8 @@ class TaskDetail(LoginRequiredMixin, View):
|
||||
"task": task,
|
||||
"events": events,
|
||||
"sync_events": sync_events,
|
||||
"codex_runs": codex_runs,
|
||||
"permission_requests": permission_requests,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -895,8 +1090,39 @@ class TaskSettings(LoginRequiredMixin, View):
|
||||
row.allowed_prefixes_csv = ",".join(row.settings_effective["allowed_prefixes"])
|
||||
provider_map = _provider_row_map(request.user)
|
||||
codex_cfg = provider_map.get("codex_cli")
|
||||
codex_settings = dict(getattr(codex_cfg, "settings", {}) or {})
|
||||
codex_settings = _codex_settings_with_defaults(dict(getattr(codex_cfg, "settings", {}) or {}))
|
||||
mock_cfg = provider_map.get("mock")
|
||||
codex_provider = get_provider("codex_cli")
|
||||
codex_healthcheck = codex_provider.healthcheck(codex_settings) if codex_cfg else None
|
||||
codex_queue_counts = {
|
||||
"pending": ExternalSyncEvent.objects.filter(
|
||||
user=request.user, provider="codex_cli", status="pending"
|
||||
).count(),
|
||||
"waiting_approval": ExternalSyncEvent.objects.filter(
|
||||
user=request.user, provider="codex_cli", status="waiting_approval"
|
||||
).count(),
|
||||
"failed": ExternalSyncEvent.objects.filter(
|
||||
user=request.user, provider="codex_cli", status="failed"
|
||||
).count(),
|
||||
"ok": ExternalSyncEvent.objects.filter(
|
||||
user=request.user, provider="codex_cli", status="ok"
|
||||
).count(),
|
||||
}
|
||||
codex_recent_runs = CodexRun.objects.filter(user=request.user).order_by("-created_at")[:10]
|
||||
latest_worker_event = (
|
||||
ExternalSyncEvent.objects.filter(
|
||||
user=request.user,
|
||||
provider="codex_cli",
|
||||
)
|
||||
.filter(status__in=["ok", "failed", "waiting_approval", "retrying"])
|
||||
.order_by("-updated_at")
|
||||
.first()
|
||||
)
|
||||
worker_heartbeat_at = getattr(latest_worker_event, "updated_at", None)
|
||||
worker_heartbeat_age = ""
|
||||
if worker_heartbeat_at is not None:
|
||||
delta_seconds = max(0, int((timezone.now() - worker_heartbeat_at).total_seconds()))
|
||||
worker_heartbeat_age = f"{delta_seconds}s ago"
|
||||
external_chat_links = list(
|
||||
ExternalChatLink.objects.filter(user=request.user).select_related(
|
||||
"person", "person_identifier"
|
||||
@@ -932,6 +1158,19 @@ class TaskSettings(LoginRequiredMixin, View):
|
||||
"default_profile": str(codex_settings.get("default_profile") or ""),
|
||||
"timeout_seconds": int(codex_settings.get("timeout_seconds") or 60),
|
||||
"chat_link_mode": str(codex_settings.get("chat_link_mode") or "task-sync"),
|
||||
"instance_label": str(codex_settings.get("instance_label") or "default"),
|
||||
"approver_service": str(codex_settings.get("approver_service") or ""),
|
||||
"approver_identifier": str(codex_settings.get("approver_identifier") or ""),
|
||||
"approver_mode": "channel",
|
||||
},
|
||||
"codex_compact_summary": {
|
||||
"healthcheck_ok": bool(getattr(codex_healthcheck, "ok", False)),
|
||||
"healthcheck_error": str(getattr(codex_healthcheck, "error", "") or ""),
|
||||
"healthcheck_payload": dict(getattr(codex_healthcheck, "payload", {}) or {}),
|
||||
"worker_heartbeat_at": worker_heartbeat_at,
|
||||
"worker_heartbeat_age": worker_heartbeat_age,
|
||||
"queue_counts": codex_queue_counts,
|
||||
"recent_runs": codex_recent_runs,
|
||||
},
|
||||
"person_identifiers": person_identifiers,
|
||||
"external_link_person_identifiers": external_link_person_identifiers,
|
||||
@@ -961,12 +1200,13 @@ class TaskSettings(LoginRequiredMixin, View):
|
||||
|
||||
if action == "epic_create":
|
||||
project = get_object_or_404(TaskProject, id=request.POST.get("project_id"), user=request.user)
|
||||
TaskEpic.objects.create(
|
||||
epic = TaskEpic.objects.create(
|
||||
project=project,
|
||||
name=str(request.POST.get("name") or "Epic").strip() or "Epic",
|
||||
external_key=str(request.POST.get("external_key") or "").strip(),
|
||||
active=bool(request.POST.get("active") or "1"),
|
||||
)
|
||||
_notify_epic_created_in_project_chats(project=project, epic=epic)
|
||||
return _settings_redirect(request)
|
||||
|
||||
if action == "source_create":
|
||||
@@ -1063,18 +1303,18 @@ class TaskSettings(LoginRequiredMixin, View):
|
||||
row.enabled = bool(request.POST.get("enabled"))
|
||||
settings_payload = dict(row.settings or {})
|
||||
if provider == "codex_cli":
|
||||
timeout_raw = str(request.POST.get("timeout_seconds") or "60").strip()
|
||||
try:
|
||||
timeout_value = max(1, int(timeout_raw))
|
||||
except Exception:
|
||||
timeout_value = 60
|
||||
settings_payload = {
|
||||
"command": str(request.POST.get("command") or "codex").strip() or "codex",
|
||||
"workspace_root": str(request.POST.get("workspace_root") or "").strip(),
|
||||
"default_profile": str(request.POST.get("default_profile") or "").strip(),
|
||||
"timeout_seconds": timeout_value,
|
||||
"chat_link_mode": "task-sync",
|
||||
}
|
||||
settings_payload = _codex_settings_with_defaults(
|
||||
{
|
||||
"command": request.POST.get("command"),
|
||||
"workspace_root": request.POST.get("workspace_root"),
|
||||
"default_profile": request.POST.get("default_profile"),
|
||||
"timeout_seconds": request.POST.get("timeout_seconds"),
|
||||
"instance_label": request.POST.get("instance_label"),
|
||||
"approver_service": request.POST.get("approver_service"),
|
||||
"approver_identifier": request.POST.get("approver_identifier"),
|
||||
"approver_mode": "channel",
|
||||
}
|
||||
)
|
||||
row.settings = settings_payload
|
||||
row.save(update_fields=["enabled", "settings", "updated_at"])
|
||||
return _settings_redirect(request)
|
||||
@@ -1159,6 +1399,196 @@ class TaskSettings(LoginRequiredMixin, View):
|
||||
return _settings_redirect(request)
|
||||
|
||||
|
||||
class TaskCodexSubmit(LoginRequiredMixin, View):
|
||||
def post(self, request):
|
||||
task_id = str(request.POST.get("task_id") or "").strip()
|
||||
next_url = str(request.POST.get("next") or reverse("tasks_hub")).strip()
|
||||
task = get_object_or_404(
|
||||
DerivedTask.objects.select_related("project", "epic", "origin_message"),
|
||||
id=task_id,
|
||||
user=request.user,
|
||||
)
|
||||
cfg = TaskProviderConfig.objects.filter(
|
||||
user=request.user,
|
||||
provider="codex_cli",
|
||||
enabled=True,
|
||||
).first()
|
||||
if cfg is None:
|
||||
messages.error(
|
||||
request,
|
||||
"Codex provider is disabled. Enable it in Task Settings first.",
|
||||
)
|
||||
return redirect(next_url)
|
||||
run = _enqueue_codex_task_submission(
|
||||
user=request.user,
|
||||
task=task,
|
||||
source_service=str(task.source_service or ""),
|
||||
source_channel=str(task.source_channel or ""),
|
||||
mode="default",
|
||||
source_message=getattr(task, "origin_message", None),
|
||||
)
|
||||
messages.success(request, f"Sent task #{task.reference_code} to Codex (run {run.id}).")
|
||||
return redirect(next_url)
|
||||
|
||||
|
||||
class CodexSettingsPage(LoginRequiredMixin, View):
|
||||
template_name = "pages/codex-settings.html"
|
||||
|
||||
def _context(self, request):
|
||||
cfg = TaskProviderConfig.objects.filter(user=request.user, provider="codex_cli").first()
|
||||
settings_payload = _codex_settings_with_defaults(dict(getattr(cfg, "settings", {}) or {}))
|
||||
provider = get_provider("codex_cli")
|
||||
health = provider.healthcheck(settings_payload) if cfg else None
|
||||
|
||||
status_filter = str(request.GET.get("status") or "").strip().lower()
|
||||
service_filter = str(request.GET.get("service") or "").strip().lower()
|
||||
channel_filter = str(request.GET.get("channel") or "").strip()
|
||||
project_filter = str(request.GET.get("project") or "").strip()
|
||||
date_from = str(request.GET.get("date_from") or "").strip()
|
||||
|
||||
runs = CodexRun.objects.filter(user=request.user).select_related("task", "project", "epic").order_by("-created_at")
|
||||
if status_filter:
|
||||
runs = runs.filter(status=status_filter)
|
||||
if service_filter:
|
||||
runs = runs.filter(source_service=service_filter)
|
||||
if channel_filter:
|
||||
runs = runs.filter(source_channel=channel_filter)
|
||||
if project_filter:
|
||||
runs = runs.filter(project_id=project_filter)
|
||||
if date_from:
|
||||
runs = runs.filter(created_at__date__gte=date_from)
|
||||
runs = runs[:200]
|
||||
|
||||
queue_counts = {
|
||||
"pending": ExternalSyncEvent.objects.filter(
|
||||
user=request.user, provider="codex_cli", status="pending"
|
||||
).count(),
|
||||
"waiting_approval": ExternalSyncEvent.objects.filter(
|
||||
user=request.user, provider="codex_cli", status="waiting_approval"
|
||||
).count(),
|
||||
"failed": ExternalSyncEvent.objects.filter(
|
||||
user=request.user, provider="codex_cli", status="failed"
|
||||
).count(),
|
||||
"ok": ExternalSyncEvent.objects.filter(
|
||||
user=request.user, provider="codex_cli", status="ok"
|
||||
).count(),
|
||||
}
|
||||
permission_requests = (
|
||||
CodexPermissionRequest.objects.filter(user=request.user)
|
||||
.select_related("codex_run", "codex_run__task", "external_sync_event")
|
||||
.order_by("-requested_at")[:200]
|
||||
)
|
||||
return {
|
||||
"provider_config": cfg,
|
||||
"provider_settings": settings_payload,
|
||||
"health": health,
|
||||
"runs": runs,
|
||||
"queue_counts": queue_counts,
|
||||
"permission_requests": permission_requests,
|
||||
"projects": TaskProject.objects.filter(user=request.user).order_by("name"),
|
||||
"filters": {
|
||||
"status": status_filter,
|
||||
"service": service_filter,
|
||||
"channel": channel_filter,
|
||||
"project": project_filter,
|
||||
"date_from": date_from,
|
||||
},
|
||||
}
|
||||
|
||||
def get(self, request):
|
||||
return render(request, self.template_name, self._context(request))
|
||||
|
||||
|
||||
class CodexApprovalAction(LoginRequiredMixin, View):
|
||||
def post(self, request):
|
||||
request_id = str(request.POST.get("request_id") or "").strip()
|
||||
decision = str(request.POST.get("decision") or "").strip().lower()
|
||||
row = get_object_or_404(
|
||||
CodexPermissionRequest.objects.select_related("codex_run", "external_sync_event"),
|
||||
id=request_id,
|
||||
user=request.user,
|
||||
)
|
||||
if row.status != "pending":
|
||||
return redirect("codex_settings")
|
||||
now = timezone.now()
|
||||
if decision == "approve":
|
||||
row.status = "approved"
|
||||
row.resolved_at = now
|
||||
row.resolved_by_identifier = "settings_ui"
|
||||
row.resolution_note = "approved via settings ui"
|
||||
row.save(
|
||||
update_fields=[
|
||||
"status",
|
||||
"resolved_at",
|
||||
"resolved_by_identifier",
|
||||
"resolution_note",
|
||||
]
|
||||
)
|
||||
run = row.codex_run
|
||||
run.status = "approved_waiting_resume"
|
||||
run.error = ""
|
||||
run.save(update_fields=["status", "error", "updated_at"])
|
||||
provider_payload = dict(run.request_payload.get("provider_payload") or {})
|
||||
provider_payload.update(
|
||||
{
|
||||
"mode": "approval_response",
|
||||
"approval_key": row.approval_key,
|
||||
"resume_payload": dict(row.resume_payload or {}),
|
||||
"codex_run_id": str(run.id),
|
||||
}
|
||||
)
|
||||
ExternalSyncEvent.objects.update_or_create(
|
||||
idempotency_key=f"codex_approval:{row.approval_key}:approved",
|
||||
defaults={
|
||||
"user": request.user,
|
||||
"task": run.task,
|
||||
"task_event": run.derived_task_event,
|
||||
"provider": "codex_cli",
|
||||
"status": "pending",
|
||||
"payload": {"action": "append_update", "provider_payload": provider_payload},
|
||||
"error": "",
|
||||
},
|
||||
)
|
||||
messages.success(request, f"Approved {row.approval_key}. Resume event queued.")
|
||||
return redirect("codex_settings")
|
||||
|
||||
row.status = "denied"
|
||||
row.resolved_at = now
|
||||
row.resolved_by_identifier = "settings_ui"
|
||||
row.resolution_note = "denied via settings ui"
|
||||
row.save(update_fields=["status", "resolved_at", "resolved_by_identifier", "resolution_note"])
|
||||
run = row.codex_run
|
||||
run.status = "denied"
|
||||
run.error = "approval_denied"
|
||||
run.save(update_fields=["status", "error", "updated_at"])
|
||||
ExternalSyncEvent.objects.update_or_create(
|
||||
idempotency_key=f"codex_approval:{row.approval_key}:denied",
|
||||
defaults={
|
||||
"user": request.user,
|
||||
"task": run.task,
|
||||
"task_event": run.derived_task_event,
|
||||
"provider": "codex_cli",
|
||||
"status": "failed",
|
||||
"payload": {
|
||||
"action": "append_update",
|
||||
"provider_payload": {
|
||||
"mode": "approval_response",
|
||||
"approval_key": row.approval_key,
|
||||
"codex_run_id": str(run.id),
|
||||
},
|
||||
},
|
||||
"error": "approval_denied",
|
||||
},
|
||||
)
|
||||
if row.external_sync_event_id:
|
||||
ExternalSyncEvent.objects.filter(id=row.external_sync_event_id).update(
|
||||
status="failed",
|
||||
error="approval_denied",
|
||||
)
|
||||
messages.warning(request, f"Denied {row.approval_key}.")
|
||||
return redirect("codex_settings")
|
||||
|
||||
|
||||
class AnswerSuggestionSend(LoginRequiredMixin, View):
|
||||
def post(self, request):
|
||||
event = get_object_or_404(
|
||||
|
||||
Reference in New Issue
Block a user