from __future__ import annotations import re from core.models import ChatTaskSource, TaskProject from core.tasks.codex_support import channel_variants 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, } WHATSAPP_GROUP_ID_RE = re.compile(r"^\d+@g\.us$") WHATSAPP_DIRECT_ID_RE = re.compile(r"^\d+@s\.whatsapp\.net$") WHATSAPP_BARE_ID_RE = re.compile(r"^\d+$") SIGNAL_GROUP_ID_RE = re.compile(r"^group\.[A-Za-z0-9+/=]+$") SIGNAL_UUID_RE = re.compile( r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", re.IGNORECASE, ) SIGNAL_PHONE_RE = re.compile(r"^\+\d+$") SIGNAL_INTERNAL_ID_RE = re.compile(r"^[A-Za-z0-9+/=]+$") def _normalize_whatsapp_identifier(identifier: str) -> str: value = str(identifier or "").strip() if not value: return "" if "/" in value or "?" in value or "#" in value: return "" if WHATSAPP_GROUP_ID_RE.fullmatch(value): return value if WHATSAPP_DIRECT_ID_RE.fullmatch(value): return value bare = value.split("@", 1)[0].strip() if not WHATSAPP_BARE_ID_RE.fullmatch(bare): return "" if value.endswith("@s.whatsapp.net"): return f"{bare}@s.whatsapp.net" return f"{bare}@g.us" def _normalize_signal_identifier(identifier: str) -> str: value = str(identifier or "").strip() if not value: return "" if SIGNAL_GROUP_ID_RE.fullmatch(value): return value if SIGNAL_UUID_RE.fullmatch(value): return value.lower() if SIGNAL_PHONE_RE.fullmatch(value): return value if SIGNAL_INTERNAL_ID_RE.fullmatch(value): return value return "" 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": return _normalize_whatsapp_identifier(value) if service_key == "signal": return _normalize_signal_identifier(value) return value def resolve_message_scope(message) -> tuple[str, str]: source_service = str(getattr(message, "source_service", "") or "").strip().lower() source_channel = str(getattr(message, "source_chat_id", "") or "").strip() if source_service != "web": return source_service, source_channel identifier = getattr(getattr(message, "session", None), "identifier", None) fallback_service = str(getattr(identifier, "service", "") or "").strip().lower() fallback_identifier = str(getattr(identifier, "identifier", "") or "").strip() if fallback_service and fallback_identifier and fallback_service != "web": return fallback_service, fallback_identifier return source_service, source_channel def _project_name_candidate(service: str, channel_identifier: str, message=None) -> str: person_name = "" if message is not None: identifier = getattr(getattr(message, "session", None), "identifier", None) person = getattr(identifier, "person", None) person_name = str(getattr(person, "name", "") or "").strip() if person_name: return person_name[:255] raw = str(channel_identifier or "").strip() if str(service or "").strip().lower() == "whatsapp": raw = raw.split("@", 1)[0].strip() cleaned = re.sub(r"\s+", " ", raw).strip() if not cleaned: cleaned = "Chat" return f"Chat: {cleaned}"[:255] def _ensure_unique_project_name(user, base_name: str) -> str: base = str(base_name or "").strip() or "Chat" if not TaskProject.objects.filter(user=user, name=base).exists(): return base idx = 2 while idx < 10000: candidate = f"{base} ({idx})"[:255] if not TaskProject.objects.filter(user=user, name=candidate).exists(): return candidate idx += 1 return f"{base} ({str(user.id)[:8]})"[:255] def ensure_default_source_for_chat( *, user, service: str, channel_identifier: str, message=None, ): service_key = str(service or "").strip().lower() normalized_identifier = normalize_channel_identifier( service_key, channel_identifier ) variants = channel_variants(service_key, normalized_identifier) if not service_key or not variants: return None existing = ( ChatTaskSource.objects.filter( user=user, service=service_key, channel_identifier__in=variants, ) .select_related("project", "epic") .order_by("-enabled", "-updated_at", "-created_at") .first() ) if existing is not None: if not existing.enabled: existing.enabled = True existing.save(update_fields=["enabled", "updated_at"]) return existing project_name = _ensure_unique_project_name( user, _project_name_candidate(service_key, normalized_identifier, message=message), ) project = TaskProject.objects.create( user=user, name=project_name, settings=dict(SAFE_TASK_FLAGS_DEFAULTS), ) return ChatTaskSource.objects.create( user=user, service=service_key, channel_identifier=normalized_identifier, project=project, epic=None, enabled=True, settings=dict(SAFE_TASK_FLAGS_DEFAULTS), )