from __future__ import annotations import re from asgiref.sync import sync_to_async from django.conf import settings from core.clients.transport import send_message_raw from core.messaging import ai as ai_runner from core.models import ( AI, Chat, ChatTaskSource, CodexRun, DerivedTask, DerivedTaskEvent, ExternalSyncEvent, Message, TaskCompletionPattern, TaskEpic, TaskProviderConfig, ) from core.tasks.providers import get_provider from core.tasks.codex_support import resolve_external_chat_id _TASK_HINT_RE = re.compile(r"\b(todo|task|action|need to|please)\b", re.IGNORECASE) _COMPLETION_RE = re.compile(r"\b(done|completed|fixed)\s*#([A-Za-z0-9_-]+)\b", re.IGNORECASE) _BALANCED_HINT_RE = re.compile(r"\b(todo|task|action item|action)\b", re.IGNORECASE) _BROAD_HINT_RE = re.compile(r"\b(todo|task|action|need to|please|reminder)\b", re.IGNORECASE) _PREFIX_HEAD_TRIM = " \t\r\n`'\"([{<*#-–—_>.,:;!/?\\|" _LIST_TASKS_RE = re.compile( r"^\s*(?:\.l(?:\s+list(?:\s+tasks?)?)?|\.list(?:\s+tasks?)?)\s*$", re.IGNORECASE, ) _UNDO_TASK_RE = re.compile( r"^\s*\.undo(?:\s+(?:#)?(?P[A-Za-z0-9_-]+))?\s*$", re.IGNORECASE, ) _EPIC_CREATE_RE = re.compile( r"^\s*(?:\.epic\b|epic)\s*[:\-]?\s*(?P.+?)\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]: 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 async def _resolve_source_mappings(message: Message) -> list[ChatTaskSource]: lookup_service = str(message.source_service or "").strip().lower() variants = _channel_variants(lookup_service, message.source_chat_id or "") session_identifier = getattr(getattr(message, "session", None), "identifier", None) canonical_service = str(getattr(session_identifier, "service", "") or "").strip().lower() canonical_identifier = str(getattr(session_identifier, "identifier", "") or "").strip() if lookup_service == "web" and canonical_service and canonical_service != "web": lookup_service = canonical_service variants = _channel_variants(lookup_service, message.source_chat_id or "") for expanded in _channel_variants(lookup_service, canonical_identifier): if expanded and expanded not in variants: variants.append(expanded) elif canonical_service and canonical_identifier and canonical_service == lookup_service: for expanded in _channel_variants(canonical_service, canonical_identifier): if expanded and expanded not in variants: variants.append(expanded) if lookup_service == "signal": companions: list[str] = [] for value in list(variants): signal_value = str(value or "").strip() if not signal_value: continue companions += await sync_to_async(list)( Chat.objects.filter(source_uuid=signal_value).values_list("source_number", flat=True) ) companions += await sync_to_async(list)( Chat.objects.filter(source_number=signal_value).values_list("source_uuid", flat=True) ) for candidate in companions: for expanded in _channel_variants("signal", str(candidate or "").strip()): if expanded and expanded not in variants: variants.append(expanded) if not variants: return [] return await sync_to_async(list)( ChatTaskSource.objects.filter( user=message.user, enabled=True, service=lookup_service, channel_identifier__in=variants, ).select_related("project", "epic") ) def _to_bool(raw, default=False) -> bool: if raw is None: return bool(default) value = str(raw).strip().lower() if value in {"1", "true", "yes", "on", "y"}: return True if value in {"0", "false", "no", "off", "n"}: return False return bool(default) def _parse_prefixes(raw) -> list[str]: if isinstance(raw, list): values = raw else: values = str(raw or "").split(",") rows = [] for row in values: item = str(row or "").strip().lower() if item and item not in rows: rows.append(item) return rows or ["task:", "todo:", "action:"] def _prefix_roots(prefixes: list[str]) -> list[str]: roots: list[str] = [] for value in prefixes: token = str(value or "").strip().lower() if not token: continue token = token.lstrip(_PREFIX_HEAD_TRIM) match = re.match(r"([a-z0-9]+)", token) if not match: continue root = str(match.group(1) or "").strip() if root and root not in roots: roots.append(root) return roots def _has_task_prefix(text: str, prefixes: list[str]) -> bool: body = str(text or "").strip().lower() if not body: return False if any(body.startswith(prefix) for prefix in prefixes): return True trimmed = body.lstrip(_PREFIX_HEAD_TRIM) roots = _prefix_roots(prefixes) if not trimmed or not roots: return False for root in roots: if re.match(rf"^{re.escape(root)}\b(?:\s*[:\-–—#>.,;!]*\s*|\s+)", trimmed): return True return False def _strip_task_prefix(text: str, prefixes: list[str]) -> str: body = str(text or "").strip() if not body: return "" trimmed = body.lstrip(_PREFIX_HEAD_TRIM) roots = _prefix_roots(prefixes) if not trimmed or not roots: return body for root in roots: match = re.match( rf"^{re.escape(root)}\b(?:\s*[:\-–—#>.,;!]*\s*|\s+)(.+)$", trimmed, flags=re.IGNORECASE | re.DOTALL, ) if match: cleaned = str(match.group(1) or "").strip() return cleaned or body return body def _normalize_flags(raw: dict | None) -> dict: row = dict(raw or {}) return { "derive_enabled": _to_bool(row.get("derive_enabled"), True), "match_mode": str(row.get("match_mode") or "balanced").strip().lower() or "balanced", "require_prefix": _to_bool(row.get("require_prefix"), False), "allowed_prefixes": _parse_prefixes(row.get("allowed_prefixes")), "completion_enabled": _to_bool(row.get("completion_enabled"), True), "ai_title_enabled": _to_bool(row.get("ai_title_enabled"), True), "announce_task_id": _to_bool(row.get("announce_task_id"), False), "min_chars": max(1, int(row.get("min_chars") or 3)), } def _normalize_partial_flags(raw: dict | None) -> dict: row = dict(raw or {}) out = {} if "derive_enabled" in row: out["derive_enabled"] = _to_bool(row.get("derive_enabled"), True) if "match_mode" in row: out["match_mode"] = str(row.get("match_mode") or "balanced").strip().lower() or "balanced" if "require_prefix" in row: out["require_prefix"] = _to_bool(row.get("require_prefix"), False) if "allowed_prefixes" in row: out["allowed_prefixes"] = _parse_prefixes(row.get("allowed_prefixes")) if "completion_enabled" in row: out["completion_enabled"] = _to_bool(row.get("completion_enabled"), True) if "ai_title_enabled" in row: out["ai_title_enabled"] = _to_bool(row.get("ai_title_enabled"), True) if "announce_task_id" in row: out["announce_task_id"] = _to_bool(row.get("announce_task_id"), False) if "min_chars" in row: out["min_chars"] = max(1, int(row.get("min_chars") or 3)) return out def _effective_flags(source: ChatTaskSource) -> dict: project_flags = _normalize_flags(getattr(getattr(source, "project", None), "settings", {}) or {}) source_flags = _normalize_partial_flags(getattr(source, "settings", {}) or {}) merged = dict(project_flags) merged.update(source_flags) return merged def _is_task_candidate(text: str, flags: dict) -> bool: body = str(text or "").strip() if len(body) < int(flags.get("min_chars") or 1): return False body_lower = body.lower() prefixes = list(flags.get("allowed_prefixes") or []) has_prefix = _has_task_prefix(body_lower, prefixes) if bool(flags.get("require_prefix")) and not has_prefix: return False mode = str(flags.get("match_mode") or "balanced").strip().lower() if mode == "strict": return has_prefix if mode == "broad": return has_prefix or bool(_BROAD_HINT_RE.search(body)) return has_prefix or bool(_BALANCED_HINT_RE.search(body)) def _next_reference(user, project) -> str: last = ( DerivedTask.objects.filter(user=user, project=project) .exclude(reference_code="") .order_by("-created_at") .first() ) if not last: return "1" try: return str(int(str(last.reference_code)) + 1) except Exception: return str(DerivedTask.objects.filter(user=user, project=project).count() + 1) async def _derive_title(message: Message) -> str: text = str(message.text or "").strip() if not text: return "Untitled task" if not bool(getattr(settings, "TASK_DERIVATION_USE_AI", True)): return text[:255] ai_obj = await sync_to_async(lambda: AI.objects.filter(user=message.user).first())() if not ai_obj: return text[:255] prompt = [ { "role": "system", "content": "Extract one concise actionable task title from the message. Return plain text only.", }, {"role": "user", "content": text[:2000]}, ] try: title = str(await ai_runner.run_prompt(prompt, ai_obj, operation="task_derive_title") or "").strip() except Exception: title = "" return (title or text)[:255] async def _derive_title_with_flags(message: Message, flags: dict) -> str: prefixes = list(flags.get("allowed_prefixes") or []) if not bool(flags.get("ai_title_enabled", True)): text = _strip_task_prefix(str(message.text or "").strip(), prefixes) return (text or "Untitled task")[:255] 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: cfg = await sync_to_async( lambda: TaskProviderConfig.objects.filter(user=task.user, enabled=True).order_by("provider").first() )() provider_name = str(getattr(cfg, "provider", "mock") or "mock") provider_settings = dict(getattr(cfg, "settings", {}) or {}) provider = get_provider(provider_name) idempotency_key = f"{provider_name}:{task.id}:{event.id}" external_chat_id = await sync_to_async(resolve_external_chat_id)( user=task.user, provider=provider_name, service=str(task.source_service or ""), channel=str(task.source_channel or ""), ) cached_project = task._state.fields_cache.get("project") cached_epic = task._state.fields_cache.get("epic") project_name = str(getattr(cached_project, "name", "") or "") epic_name = str(getattr(cached_epic, "name", "") or "") request_payload = { "task_id": str(task.id), "reference_code": str(task.reference_code or ""), "title": str(task.title or ""), "external_key": str(task.external_key or ""), "project_name": project_name, "epic_name": epic_name, "source_service": str(task.source_service or ""), "source_channel": str(task.source_channel or ""), "external_chat_id": external_chat_id, "origin_message_id": str(getattr(task, "origin_message_id", "") or ""), "trigger_message_id": str(getattr(event, "source_message_id", "") or getattr(task, "origin_message_id", "") or ""), "mode": "default", "payload": event.payload, } codex_run = await sync_to_async(CodexRun.objects.create)( user=task.user, task_id=task.id, derived_task_event_id=event.id, source_message_id=(event.source_message_id or task.origin_message_id), project_id=task.project_id, epic_id=task.epic_id, source_service=str(task.source_service or ""), source_channel=str(task.source_channel or ""), external_chat_id=external_chat_id, status="queued", request_payload={ "action": action, "provider_payload": dict(request_payload), "idempotency_key": idempotency_key, }, result_payload={}, error="", ) request_payload["codex_run_id"] = str(codex_run.id) # Worker-backed providers are queued and executed by `manage.py codex_worker`. if bool(getattr(provider, "run_in_worker", False)): await sync_to_async(ExternalSyncEvent.objects.update_or_create)( idempotency_key=idempotency_key, defaults={ "user": task.user, "task": task, "task_event": event, "provider": provider_name, "status": "pending", "payload": { "action": action, "provider_payload": dict(request_payload), }, "error": "", }, ) return if action == "create": result = provider.create_task(provider_settings, dict(request_payload)) elif action == "complete": result = provider.mark_complete(provider_settings, dict(request_payload)) else: result = provider.append_update(provider_settings, dict(request_payload)) status = "ok" if result.ok else "failed" await sync_to_async(ExternalSyncEvent.objects.update_or_create)( idempotency_key=idempotency_key, defaults={ "user": task.user, "task": task, "task_event": event, "provider": provider_name, "status": status, "payload": dict(result.payload 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: task.external_key = str(result.external_key) await sync_to_async(task.save)(update_fields=["external_key"]) async def _completion_regex(message: Message) -> re.Pattern: patterns = await sync_to_async(list)( TaskCompletionPattern.objects.filter(user=message.user, enabled=True).order_by("position", "created_at") ) phrases = [str(row.phrase or "").strip() for row in patterns if str(row.phrase or "").strip()] if not phrases: phrases = ["done", "completed", "fixed"] 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: (or .epic )\n" "- add task to epic: task: [epic:]\n" "- list tasks: .l list tasks\n" "- undo latest task: .undo" ), ) return True async def process_inbound_task_intelligence(message: Message) -> None: if message is None: return if dict(message.message_meta or {}).get("origin_tag"): return text = str(message.text or "").strip() if not text: return sources = await _resolve_source_mappings(message) if not sources: return if await _handle_scope_task_commands(message, sources, text): return if await _handle_epic_create_command(message, sources, text): return completion_allowed = any(bool(_effective_flags(source).get("completion_enabled")) for source in sources) completion_rx = await _completion_regex(message) if completion_allowed else None marker_match = (completion_rx.search(text) if completion_rx else None) or (_COMPLETION_RE.search(text) if completion_allowed else None) if marker_match: ref_code = str(marker_match.group(marker_match.lastindex or 1) or "").strip() task = await sync_to_async( lambda: DerivedTask.objects.filter(user=message.user, reference_code=ref_code).order_by("-created_at").first() )() if not task: # parser warning event attached to a newly derived placeholder in mapped project source = sources[0] placeholder = await sync_to_async(DerivedTask.objects.create)( user=message.user, project=source.project, epic=source.epic, title=f"Unresolved completion marker #{ref_code}", source_service=message.source_service or "web", source_channel=message.source_chat_id or "", origin_message=message, reference_code=ref_code, status_snapshot="warning", immutable_payload={"warning": "completion_marker_unresolved"}, ) await sync_to_async(DerivedTaskEvent.objects.create)( task=placeholder, event_type="parse_warning", actor_identifier=str(message.sender_uuid or ""), source_message=message, payload={"reason": "completion_marker_unresolved", "marker": ref_code}, ) return task.status_snapshot = "completed" await sync_to_async(task.save)(update_fields=["status_snapshot"]) event = await sync_to_async(DerivedTaskEvent.objects.create)( task=task, event_type="completion_marked", actor_identifier=str(message.sender_uuid or ""), source_message=message, payload={"marker": ref_code}, ) await _emit_sync_event(task, event, "complete") return for source in sources: flags = _effective_flags(source) if not bool(flags.get("derive_enabled", True)): continue task_text = _strip_epic_token(text) if not _is_task_candidate(task_text, flags): continue epic = source.epic epic_name = _extract_epic_name_from_text(text) if epic_name: epic, _ = await sync_to_async(TaskEpic.objects.get_or_create)( project=source.project, name=epic_name, ) cloned_message = message if task_text != text: cloned_message = Message( user=message.user, text=task_text, source_service=message.source_service, source_chat_id=message.source_chat_id, ) title = await _derive_title_with_flags(cloned_message, flags) reference = await sync_to_async(_next_reference)(message.user, source.project) task = await sync_to_async(DerivedTask.objects.create)( user=message.user, project=source.project, epic=epic, title=title, source_service=source.service or message.source_service or "web", source_channel=source.channel_identifier or message.source_chat_id or "", origin_message=message, reference_code=reference, status_snapshot="open", immutable_payload={"origin_text": text, "task_text": task_text, "flags": flags}, ) event = await sync_to_async(DerivedTaskEvent.objects.create)( task=task, event_type="created", actor_identifier=str(message.sender_uuid or ""), source_message=message, payload={"origin_text": text}, ) await _emit_sync_event(task, event, "create") if bool(flags.get("announce_task_id", False)): try: await send_message_raw( source.service or message.source_service or "web", source.channel_identifier or message.source_chat_id or "", text=f"[task] Created #{task.reference_code}: {task.title}", attachments=[], metadata={"origin": "task_announce"}, ) except Exception: # Announcement is best-effort and should not block derivation. pass scope_count = await sync_to_async( lambda: DerivedTask.objects.filter( user=message.user, project=source.project, source_service=source.service, source_channel=source.channel_identifier, ).count() )() if scope_count > 0 and scope_count % 10 == 0: try: await send_message_raw( source.service or message.source_service or "web", source.channel_identifier or message.source_chat_id or "", text="[task] tip: use .l list tasks to review tasks. use .undo to uncreate the latest task.", attachments=[], metadata={"origin": "task_reminder"}, ) except Exception: pass