Lightweight containerized prosody tooling + moved auth scripts + xmpp reconnect/auth stabilization
This commit is contained in:
@@ -30,6 +30,7 @@ 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.events.ledger import append_event_sync
|
||||
from core.messaging import ai as ai_runner
|
||||
from core.messaging import history
|
||||
from core.messaging import media_bridge
|
||||
@@ -53,6 +54,7 @@ from core.models import (
|
||||
from core.presence import get_settings as get_availability_settings
|
||||
from core.presence import spans_for_range
|
||||
from core.realtime.typing_state import get_person_typing_state
|
||||
from core.transports.capabilities import supports, unsupported_reason
|
||||
from core.translation.engine import process_inbound_translation
|
||||
from core.views.workspace import (
|
||||
INSIGHT_METRICS,
|
||||
@@ -516,7 +518,8 @@ def _serialize_message(msg: Message) -> dict:
|
||||
emoji = str(item.get("emoji") or "").strip()
|
||||
if not emoji:
|
||||
continue
|
||||
actor = str(item.get("actor") or "").strip()
|
||||
# Keep actor/source normalization stable to avoid duplicate/hiding issues.
|
||||
actor = str(item.get("actor") or "").strip().lower()
|
||||
source = str(item.get("source_service") or "").strip().lower()
|
||||
key = (emoji, actor, source)
|
||||
if key in seen_reactions:
|
||||
@@ -1811,6 +1814,7 @@ def _reaction_actor_key(user_id, service: str) -> str:
|
||||
|
||||
def _resolve_reaction_target(message: Message, service: str, channel_identifier: str) -> dict:
|
||||
service_key = _default_service(service)
|
||||
message_source_service = str(getattr(message, "source_service", "") or "").strip().lower()
|
||||
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()
|
||||
@@ -1819,23 +1823,23 @@ def _resolve_reaction_target(message: Message, service: str, channel_identifier:
|
||||
|
||||
if service_key == "signal":
|
||||
target_ts = 0
|
||||
if source_message_id.isdigit():
|
||||
if message_source_service == "signal" and source_message_id.isdigit():
|
||||
target_ts = int(source_message_id)
|
||||
bridge_ref = _latest_signal_bridge_ref(message)
|
||||
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:
|
||||
# Local web messages are only reactable once bridge refs exist.
|
||||
if not target_ts and message_source_service == "signal":
|
||||
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()
|
||||
@@ -1856,10 +1860,10 @@ def _resolve_reaction_target(message: Message, service: str, channel_identifier:
|
||||
}
|
||||
|
||||
if service_key == "whatsapp":
|
||||
target_message_id = source_message_id
|
||||
target_message_id = source_message_id if message_source_service == "whatsapp" else ""
|
||||
target_ts = delivered_ts or local_ts
|
||||
bridge_ref = _latest_whatsapp_bridge_ref(message)
|
||||
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)
|
||||
@@ -4357,7 +4361,7 @@ class ComposeEngageSend(LoginRequiredMixin, View):
|
||||
identifier=base["person_identifier"],
|
||||
)
|
||||
ts_value = int(ts) if str(ts).isdigit() else int(time.time() * 1000)
|
||||
Message.objects.create(
|
||||
created = Message.objects.create(
|
||||
user=request.user,
|
||||
session=session,
|
||||
sender_uuid="",
|
||||
@@ -4366,6 +4370,23 @@ class ComposeEngageSend(LoginRequiredMixin, View):
|
||||
delivered_ts=ts_value if str(ts).isdigit() else None,
|
||||
custom_author="USER",
|
||||
)
|
||||
try:
|
||||
append_event_sync(
|
||||
user=request.user,
|
||||
session=session,
|
||||
ts=ts_value,
|
||||
event_type="message_created",
|
||||
direction="out",
|
||||
actor_identifier="USER",
|
||||
origin_transport="web",
|
||||
origin_message_id=str(created.id),
|
||||
origin_chat_id=str(base["identifier"] or ""),
|
||||
payload={"message_id": str(created.id), "text": outbound},
|
||||
raw_payload={},
|
||||
trace_id="",
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return JsonResponse({"ok": True, "message": "Shared engage sent."})
|
||||
|
||||
@@ -4519,6 +4540,23 @@ class ComposeSend(LoginRequiredMixin, View):
|
||||
),
|
||||
message_meta={},
|
||||
)
|
||||
try:
|
||||
append_event_sync(
|
||||
user=request.user,
|
||||
session=session,
|
||||
ts=int(ts),
|
||||
event_type="message_created",
|
||||
direction="out",
|
||||
actor_identifier="USER",
|
||||
origin_transport="web",
|
||||
origin_message_id=str(created_message.id),
|
||||
origin_chat_id=str(base["identifier"] or ""),
|
||||
payload={"message_id": str(created_message.id), "text": text},
|
||||
raw_payload={},
|
||||
trace_id="",
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
command_id = transport.enqueue_runtime_command(
|
||||
base["service"],
|
||||
"send_message_raw",
|
||||
@@ -4591,6 +4629,23 @@ class ComposeSend(LoginRequiredMixin, View):
|
||||
reply_source_message_id=str(reply_to.id) if reply_to is not None else None,
|
||||
message_meta={},
|
||||
)
|
||||
try:
|
||||
append_event_sync(
|
||||
user=request.user,
|
||||
session=session,
|
||||
ts=msg_ts,
|
||||
event_type="message_created",
|
||||
direction="out",
|
||||
actor_identifier="USER",
|
||||
origin_transport="web",
|
||||
origin_message_id=str(created_message.id),
|
||||
origin_chat_id=str(base["identifier"] or ""),
|
||||
payload={"message_id": str(created_message.id), "text": text},
|
||||
raw_payload={},
|
||||
trace_id="",
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
if created_message is not None:
|
||||
async_to_sync(process_inbound_message)(
|
||||
CommandContext(
|
||||
@@ -4643,6 +4698,14 @@ class ComposeReact(LoginRequiredMixin, View):
|
||||
service_key = _default_service(service)
|
||||
if service_key not in {"signal", "whatsapp"}:
|
||||
return JsonResponse({"ok": False, "error": "service_not_supported"})
|
||||
if bool(getattr(settings, "CAPABILITY_ENFORCEMENT_ENABLED", True)) and not supports(service_key, "reactions"):
|
||||
return JsonResponse(
|
||||
{
|
||||
"ok": False,
|
||||
"error": "unsupported_action",
|
||||
"reason": unsupported_reason(service_key, "reactions"),
|
||||
}
|
||||
)
|
||||
if not identifier and person is None:
|
||||
return JsonResponse({"ok": False, "error": "missing_scope"})
|
||||
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
from django.http import JsonResponse
|
||||
from django.shortcuts import render
|
||||
from django.views import View
|
||||
|
||||
from core.models import (
|
||||
AdapterHealthEvent,
|
||||
AIRequest,
|
||||
AIResult,
|
||||
AIResultSignal,
|
||||
Chat,
|
||||
ChatSession,
|
||||
ConversationEvent,
|
||||
Group,
|
||||
MemoryItem,
|
||||
Message,
|
||||
@@ -25,6 +28,8 @@ from core.models import (
|
||||
WorkspaceConversation,
|
||||
WorkspaceMetricSnapshot,
|
||||
)
|
||||
from core.events.projection import shadow_compare_session
|
||||
from core.transports.capabilities import capability_snapshot
|
||||
from core.views.manage.permissions import SuperUserRequiredMixin
|
||||
|
||||
|
||||
@@ -37,6 +42,8 @@ class SystemSettings(SuperUserRequiredMixin, View):
|
||||
"messages": Message.objects.filter(user=user).count(),
|
||||
"queued_messages": QueuedMessage.objects.filter(user=user).count(),
|
||||
"message_events": MessageEvent.objects.filter(user=user).count(),
|
||||
"conversation_events": ConversationEvent.objects.filter(user=user).count(),
|
||||
"adapter_health_events": AdapterHealthEvent.objects.filter(user=user).count(),
|
||||
"workspace_conversations": WorkspaceConversation.objects.filter(
|
||||
user=user
|
||||
).count(),
|
||||
@@ -85,6 +92,8 @@ class SystemSettings(SuperUserRequiredMixin, View):
|
||||
conversation__user=user
|
||||
).delete()[0]
|
||||
deleted += MessageEvent.objects.filter(user=user).delete()[0]
|
||||
deleted += ConversationEvent.objects.filter(user=user).delete()[0]
|
||||
deleted += AdapterHealthEvent.objects.filter(user=user).delete()[0]
|
||||
deleted += Message.objects.filter(user=user).delete()[0]
|
||||
deleted += QueuedMessage.objects.filter(user=user).delete()[0]
|
||||
deleted += WorkspaceConversation.objects.filter(user=user).delete()[0]
|
||||
@@ -156,3 +165,97 @@ class SystemSettings(SuperUserRequiredMixin, View):
|
||||
"notice_message": notice_message,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class ServiceCapabilitySnapshotAPI(SuperUserRequiredMixin, View):
|
||||
def get(self, request):
|
||||
service = str(request.GET.get("service") or "").strip().lower()
|
||||
return JsonResponse(
|
||||
{
|
||||
"ok": True,
|
||||
"data": capability_snapshot(service),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class AdapterHealthSummaryAPI(SuperUserRequiredMixin, View):
|
||||
def get(self, request):
|
||||
latest_by_service = {}
|
||||
rows = AdapterHealthEvent.objects.order_by("service", "-ts")[:200]
|
||||
for row in rows:
|
||||
key = str(row.service or "").strip().lower()
|
||||
if key in latest_by_service:
|
||||
continue
|
||||
latest_by_service[key] = {
|
||||
"status": str(row.status or ""),
|
||||
"reason": str(row.reason or ""),
|
||||
"ts": int(row.ts or 0),
|
||||
"created_at": row.created_at.isoformat(),
|
||||
}
|
||||
return JsonResponse({"ok": True, "services": latest_by_service})
|
||||
|
||||
|
||||
class TraceDiagnosticsAPI(SuperUserRequiredMixin, View):
|
||||
def get(self, request):
|
||||
trace_id = str(request.GET.get("trace_id") or "").strip()
|
||||
if not trace_id:
|
||||
return JsonResponse(
|
||||
{"ok": False, "error": "trace_id_required"},
|
||||
status=400,
|
||||
)
|
||||
rows = list(
|
||||
ConversationEvent.objects.filter(
|
||||
user=request.user,
|
||||
trace_id=trace_id,
|
||||
)
|
||||
.select_related("session")
|
||||
.order_by("ts", "created_at")[:500]
|
||||
)
|
||||
return JsonResponse(
|
||||
{
|
||||
"ok": True,
|
||||
"trace_id": trace_id,
|
||||
"count": len(rows),
|
||||
"events": [
|
||||
{
|
||||
"id": str(row.id),
|
||||
"ts": int(row.ts or 0),
|
||||
"event_type": str(row.event_type or ""),
|
||||
"direction": str(row.direction or ""),
|
||||
"session_id": str(row.session_id or ""),
|
||||
"origin_transport": str(row.origin_transport or ""),
|
||||
"origin_message_id": str(row.origin_message_id or ""),
|
||||
"payload": dict(row.payload or {}),
|
||||
}
|
||||
for row in rows
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class EventProjectionShadowAPI(SuperUserRequiredMixin, View):
|
||||
def get(self, request):
|
||||
session_id = str(request.GET.get("session_id") or "").strip()
|
||||
if not session_id:
|
||||
return JsonResponse(
|
||||
{"ok": False, "error": "session_id_required"},
|
||||
status=400,
|
||||
)
|
||||
detail_limit = int(request.GET.get("detail_limit") or 25)
|
||||
session = ChatSession.objects.filter(
|
||||
id=session_id,
|
||||
user=request.user,
|
||||
).first()
|
||||
if session is None:
|
||||
return JsonResponse(
|
||||
{"ok": False, "error": "session_not_found"},
|
||||
status=404,
|
||||
)
|
||||
compared = shadow_compare_session(session, detail_limit=max(0, detail_limit))
|
||||
return JsonResponse(
|
||||
{
|
||||
"ok": True,
|
||||
"result": compared,
|
||||
"cause_summary": dict(compared.get("cause_counts") or {}),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -35,20 +35,14 @@ from core.models import (
|
||||
ExternalChatLink,
|
||||
)
|
||||
from core.tasks.codex_support import resolve_external_chat_id
|
||||
from core.tasks.chat_defaults import (
|
||||
SAFE_TASK_FLAGS_DEFAULTS,
|
||||
ensure_default_source_for_chat,
|
||||
normalize_channel_identifier,
|
||||
)
|
||||
from core.tasks.codex_approval import queue_codex_event_with_pre_approval
|
||||
from core.tasks.providers import get_provider
|
||||
|
||||
SAFE_TASK_FLAGS_DEFAULTS = {
|
||||
"derive_enabled": True,
|
||||
"match_mode": "strict",
|
||||
"require_prefix": True,
|
||||
"allowed_prefixes": ["task:", "todo:"],
|
||||
"completion_enabled": True,
|
||||
"ai_title_enabled": True,
|
||||
"announce_task_id": False,
|
||||
"min_chars": 3,
|
||||
}
|
||||
|
||||
|
||||
def _to_bool(raw, default=False) -> bool:
|
||||
if raw is None:
|
||||
return bool(default)
|
||||
@@ -385,7 +379,7 @@ def _enqueue_codex_task_submission(
|
||||
source_service=str(source_service or ""),
|
||||
source_channel=str(source_channel or ""),
|
||||
external_chat_id=external_chat_id,
|
||||
status="queued",
|
||||
status="waiting_approval",
|
||||
request_payload={"action": "append_update", "provider_payload": dict(provider_payload)},
|
||||
result_payload={},
|
||||
error="",
|
||||
@@ -396,51 +390,21 @@ def _enqueue_codex_task_submission(
|
||||
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(
|
||||
queue_codex_event_with_pre_approval(
|
||||
user=user,
|
||||
run=run,
|
||||
task=task,
|
||||
task_event=None,
|
||||
action="append_update",
|
||||
provider_payload=dict(provider_payload),
|
||||
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()
|
||||
if not value:
|
||||
return ""
|
||||
if service_key == "whatsapp":
|
||||
bare = value.split("@", 1)[0].strip()
|
||||
if bare:
|
||||
if value.endswith("@g.us"):
|
||||
return f"{bare}@g.us"
|
||||
if value.endswith("@s.whatsapp.net"):
|
||||
return f"{bare}@s.whatsapp.net"
|
||||
return f"{bare}@g.us"
|
||||
if service_key == "signal":
|
||||
return value
|
||||
if service_key == "xmpp":
|
||||
return value
|
||||
if service_key == "instagram":
|
||||
return value
|
||||
if service_key == "web":
|
||||
return value
|
||||
return value
|
||||
|
||||
|
||||
def _upsert_group_source(*, user, service: str, channel_identifier: str, project, epic=None):
|
||||
normalized_service = str(service or "").strip().lower()
|
||||
normalized_identifier = _normalize_channel_identifier(service, channel_identifier)
|
||||
normalized_identifier = normalize_channel_identifier(service, channel_identifier)
|
||||
if not normalized_service or not normalized_identifier:
|
||||
return None
|
||||
source, created = ChatTaskSource.objects.get_or_create(
|
||||
@@ -503,6 +467,28 @@ def _notify_epic_created_in_project_chats(*, project: TaskProject, epic: TaskEpi
|
||||
continue
|
||||
|
||||
|
||||
def _reseed_chat_sources_for_deleted_project(user, service_channel_rows: list[tuple[str, str]]) -> int:
|
||||
restored = 0
|
||||
seen: set[tuple[str, str]] = set()
|
||||
for service, channel_identifier in service_channel_rows:
|
||||
service_key = str(service or "").strip().lower()
|
||||
channel = str(channel_identifier or "").strip()
|
||||
if not service_key or not channel:
|
||||
continue
|
||||
pair = (service_key, channel)
|
||||
if pair in seen:
|
||||
continue
|
||||
seen.add(pair)
|
||||
source = ensure_default_source_for_chat(
|
||||
user=user,
|
||||
service=service_key,
|
||||
channel_identifier=channel,
|
||||
)
|
||||
if source is not None:
|
||||
restored += 1
|
||||
return restored
|
||||
|
||||
|
||||
def _person_identifier_scope_variants(service: str, identifier: str) -> list[str]:
|
||||
service_key = str(service or "").strip().lower()
|
||||
raw_identifier = str(identifier or "").strip()
|
||||
@@ -668,14 +654,17 @@ class TasksHub(LoginRequiredMixin, View):
|
||||
|
||||
def _context(self, request):
|
||||
scope = self._scope(request)
|
||||
projects = (
|
||||
show_empty = bool(str(request.GET.get("show_empty") or "").strip() in {"1", "true", "yes", "on"})
|
||||
all_projects = (
|
||||
TaskProject.objects.filter(user=request.user)
|
||||
.annotate(
|
||||
task_count=Count("derived_tasks"),
|
||||
epic_count=Count("epics", distinct=True),
|
||||
source_count=Count("chat_sources", distinct=True),
|
||||
)
|
||||
.order_by("name")
|
||||
)
|
||||
projects = all_projects if show_empty else all_projects.filter(task_count__gt=0)
|
||||
tasks = (
|
||||
DerivedTask.objects.filter(user=request.user)
|
||||
.select_related("project", "epic", "origin_message")
|
||||
@@ -684,10 +673,7 @@ class TasksHub(LoginRequiredMixin, View):
|
||||
tasks = _apply_task_creator_labels(request.user, tasks)
|
||||
selected_project = None
|
||||
if scope["selected_project_id"]:
|
||||
selected_project = TaskProject.objects.filter(
|
||||
user=request.user,
|
||||
id=scope["selected_project_id"],
|
||||
).first()
|
||||
selected_project = all_projects.filter(id=scope["selected_project_id"]).first()
|
||||
person_identifiers = []
|
||||
person_identifier_rows = []
|
||||
if scope["person"] is not None:
|
||||
@@ -719,10 +705,12 @@ class TasksHub(LoginRequiredMixin, View):
|
||||
)
|
||||
return {
|
||||
"projects": projects,
|
||||
"project_choices": all_projects,
|
||||
"tasks": tasks,
|
||||
"scope": scope,
|
||||
"person_identifier_rows": person_identifier_rows,
|
||||
"selected_project": selected_project,
|
||||
"show_empty_projects": show_empty,
|
||||
}
|
||||
|
||||
def get(self, request):
|
||||
@@ -802,9 +790,25 @@ class TasksHub(LoginRequiredMixin, View):
|
||||
id=request.POST.get("project_id"),
|
||||
user=request.user,
|
||||
)
|
||||
confirm_name = str(request.POST.get("confirm_name") or "").strip()
|
||||
expected = str(project.name or "").strip()
|
||||
if confirm_name != expected:
|
||||
messages.error(
|
||||
request,
|
||||
f"Delete cancelled. Type the project name exactly to confirm deletion: {expected}",
|
||||
)
|
||||
return redirect("tasks_hub")
|
||||
mapped_channels = list(project.chat_sources.values_list("service", "channel_identifier"))
|
||||
deleted_name = str(project.name or "").strip() or "Project"
|
||||
project.delete()
|
||||
messages.success(request, f"Deleted project '{deleted_name}'.")
|
||||
restored = _reseed_chat_sources_for_deleted_project(request.user, mapped_channels)
|
||||
if restored > 0:
|
||||
messages.success(
|
||||
request,
|
||||
f"Deleted project '{deleted_name}'. Restored {restored} chat mapping(s) with default projects.",
|
||||
)
|
||||
else:
|
||||
messages.success(request, f"Deleted project '{deleted_name}'.")
|
||||
return redirect("tasks_hub")
|
||||
|
||||
return redirect("tasks_hub")
|
||||
@@ -891,9 +895,25 @@ class TaskProjectDetail(LoginRequiredMixin, View):
|
||||
return redirect("tasks_project", project_id=str(project.id))
|
||||
|
||||
if action == "project_delete":
|
||||
confirm_name = str(request.POST.get("confirm_name") or "").strip()
|
||||
expected = str(project.name or "").strip()
|
||||
if confirm_name != expected:
|
||||
messages.error(
|
||||
request,
|
||||
f"Delete cancelled. Type the project name exactly to confirm deletion: {expected}",
|
||||
)
|
||||
return redirect("tasks_project", project_id=str(project.id))
|
||||
mapped_channels = list(project.chat_sources.values_list("service", "channel_identifier"))
|
||||
deleted_name = str(project.name or "").strip() or "Project"
|
||||
project.delete()
|
||||
messages.success(request, f"Deleted project '{deleted_name}'.")
|
||||
restored = _reseed_chat_sources_for_deleted_project(request.user, mapped_channels)
|
||||
if restored > 0:
|
||||
messages.success(
|
||||
request,
|
||||
f"Deleted project '{deleted_name}'. Restored {restored} chat mapping(s) with default projects.",
|
||||
)
|
||||
else:
|
||||
messages.success(request, f"Deleted project '{deleted_name}'.")
|
||||
return redirect("tasks_hub")
|
||||
|
||||
return redirect("tasks_project", project_id=str(project.id))
|
||||
@@ -928,6 +948,17 @@ class TaskGroupDetail(LoginRequiredMixin, View):
|
||||
channel_identifier__in=variants,
|
||||
).select_related("project", "epic")
|
||||
mappings = list(mappings)
|
||||
if not mappings:
|
||||
seeded = ensure_default_source_for_chat(
|
||||
user=request.user,
|
||||
service=channel["service_key"],
|
||||
channel_identifier=channel["display_identifier"],
|
||||
)
|
||||
if seeded is not None:
|
||||
mappings = list(
|
||||
ChatTaskSource.objects.filter(id=seeded.id)
|
||||
.select_related("project", "epic")
|
||||
)
|
||||
for row in mappings:
|
||||
row_channel = _resolve_channel_display(
|
||||
request.user,
|
||||
@@ -962,6 +993,7 @@ class TaskGroupDetail(LoginRequiredMixin, View):
|
||||
"channel_display_name": channel["display_name"],
|
||||
"projects": TaskProject.objects.filter(user=request.user).order_by("name"),
|
||||
"mappings": mappings,
|
||||
"primary_project": mappings[0].project if mappings else None,
|
||||
"tasks": tasks,
|
||||
},
|
||||
)
|
||||
@@ -1015,6 +1047,35 @@ class TaskGroupDetail(LoginRequiredMixin, View):
|
||||
epic=epic,
|
||||
)
|
||||
messages.success(request, f"Mapped '{project.name}' to this group.")
|
||||
elif action == "group_project_rename":
|
||||
current = (
|
||||
ChatTaskSource.objects.filter(
|
||||
user=request.user,
|
||||
service=channel["service_key"],
|
||||
channel_identifier=channel["display_identifier"],
|
||||
enabled=True,
|
||||
)
|
||||
.select_related("project")
|
||||
.order_by("-updated_at")
|
||||
.first()
|
||||
)
|
||||
if current is None:
|
||||
current = ensure_default_source_for_chat(
|
||||
user=request.user,
|
||||
service=channel["service_key"],
|
||||
channel_identifier=channel["display_identifier"],
|
||||
)
|
||||
new_name = str(request.POST.get("project_name") or "").strip()
|
||||
if current is None or current.project is None:
|
||||
messages.error(request, "No mapped project found for this chat.")
|
||||
elif not new_name:
|
||||
messages.error(request, "Project name is required.")
|
||||
elif TaskProject.objects.filter(user=request.user, name=new_name).exclude(id=current.project_id).exists():
|
||||
messages.error(request, f"Project '{new_name}' already exists.")
|
||||
else:
|
||||
current.project.name = new_name
|
||||
current.project.save(update_fields=["name", "updated_at"])
|
||||
messages.success(request, f"Renamed project to '{new_name}'.")
|
||||
return redirect(
|
||||
"tasks_group",
|
||||
service=channel["service_key"],
|
||||
@@ -1427,7 +1488,10 @@ class TaskCodexSubmit(LoginRequiredMixin, View):
|
||||
mode="default",
|
||||
source_message=getattr(task, "origin_message", None),
|
||||
)
|
||||
messages.success(request, f"Sent task #{task.reference_code} to Codex (run {run.id}).")
|
||||
messages.success(
|
||||
request,
|
||||
f"Queued approval for task #{task.reference_code} before Codex run {run.id}.",
|
||||
)
|
||||
return redirect(next_url)
|
||||
|
||||
|
||||
@@ -1524,28 +1588,49 @@ class CodexApprovalAction(LoginRequiredMixin, View):
|
||||
"resolution_note",
|
||||
]
|
||||
)
|
||||
if row.external_sync_event_id:
|
||||
ExternalSyncEvent.objects.filter(id=row.external_sync_event_id).update(
|
||||
status="ok",
|
||||
error="",
|
||||
)
|
||||
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),
|
||||
}
|
||||
)
|
||||
resume_payload = dict(row.resume_payload or {})
|
||||
resume_action = str(resume_payload.get("action") or "").strip().lower()
|
||||
resume_provider_payload = dict(resume_payload.get("provider_payload") or {})
|
||||
if resume_action and resume_provider_payload:
|
||||
provider_payload = dict(resume_provider_payload)
|
||||
provider_payload["codex_run_id"] = str(run.id)
|
||||
event_action = resume_action
|
||||
resume_idempotency_key = str(resume_payload.get("idempotency_key") or "").strip()
|
||||
resume_event_key = (
|
||||
resume_idempotency_key
|
||||
if resume_idempotency_key
|
||||
else f"codex_approval:{row.approval_key}:approved"
|
||||
)
|
||||
else:
|
||||
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),
|
||||
}
|
||||
)
|
||||
event_action = "append_update"
|
||||
resume_event_key = f"codex_approval:{row.approval_key}:approved"
|
||||
ExternalSyncEvent.objects.update_or_create(
|
||||
idempotency_key=f"codex_approval:{row.approval_key}:approved",
|
||||
idempotency_key=resume_event_key,
|
||||
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},
|
||||
"payload": {"action": event_action, "provider_payload": provider_payload},
|
||||
"error": "",
|
||||
},
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user