import asyncio import base64 from pathlib import Path import re from urllib.parse import urlparse import shortuuid from kittykat_agent.core.model import PaginationMeta from typing import Coroutine, Iterable, List, Optional, TypeVar, Generic from pydantic import BaseModel import json import copy from typing import Any, Dict from logger import logger T = TypeVar("T") class BaseApiResponse(BaseModel, Generic[T]): status_code: int message: str data: Optional[T] = None def build_pagination_meta(skip: int, limit: int, total: int) -> PaginationMeta: has_next = skip + limit < total has_previous = skip > 0 return PaginationMeta( total=total, skip=skip, limit=limit, has_next=has_next, has_previous=has_previous, next_skip=(skip + limit) if has_next else None, previous_skip=(max(skip - limit, 0) if has_previous else None) ) def get_filename_from_url(url: str) -> str: """ Extracts the base filename (without extension) from a URL. Example: https://cdn.example.com/images/my_image_123.png -> "my_image_123" """ return Path(urlparse(url).path).stem def get_extension_from_url(url: str) -> str: """ Extracts the file extension (without the dot) from a URL. Example: https://cdn.example.com/images/my_image_123.png -> "png" """ return Path(urlparse(url).path).suffix.lstrip(".") def get_image_type_from_base64(b64_string: str) -> str: """ Detects the image type (png, jpeg, gif, webp, etc.) from a base64 string. Falls back to 'png' if type cannot be determined. """ match = re.match(r"^data:image/([a-zA-Z0-9+]+);base64,", b64_string) if match: return match.group(1).lower() # Remove prefix if present if "," in b64_string: b64_string = b64_string.split(",", 1)[1] try: # decode first bytes only decoded = base64.b64decode(b64_string[:100], validate=True) except Exception: return "png" # fallback if invalid base64 if decoded.startswith(b"\x89PNG\r\n\x1a\n"): return "png" elif decoded.startswith(b"\xff\xd8\xff"): return "jpeg" elif decoded.startswith(b"GIF87a") or decoded.startswith(b"GIF89a"): return "gif" elif decoded.startswith(b"RIFF") and decoded[8:12] == b"WEBP": return "webp" # ✅ Fallback return "png" def slugify(text: str) -> str: # Lowercase and strip text = text.strip().lower() # Replace all non-alphanumeric characters (including spaces) with underscores text = re.sub(r"[^\w]", "_", text) # Collapse multiple consecutive underscores to a single underscore text = re.sub(r"_+", "_", text) # Optional: Remove leading/trailing underscores text = text.strip("_") return text def generate_team_name(name: Optional[str] = None, email: Optional[str] = None) -> str: """ Generate a clean team name from user name or email. Args: name: User's full name (preferred) email: User's email (fallback) Returns: Formatted team name like "John Doe's workspace" Example: generate_team_name("John Doe") -> "John Doe's workspace" generate_team_name(None, "john.doe@example.com") -> "John's workspace" """ if name and name.strip(): source = name.strip() elif email: # Extract username part from email source = email.split("@")[0] else: source = "User" # Remove extra whitespace cleaned = re.sub(r"\s+", " ", source) # Remove special characters, keep only alphanumeric and spaces cleaned = re.sub(r"[^A-Za-z0-9\s]", "", cleaned) # Title case and create team name team_name = f"{cleaned.strip().title()}'s workspace" return team_name def build_custom_filename( brand_name: Optional[str], campaign_title: Optional[str], output_format: str ) -> str: brand_slug = slugify(brand_name) if brand_name else "brand" campaign_slug = slugify(campaign_title) if campaign_title else None uid = shortuuid.uuid()[:8] ext = output_format.lower() if campaign_slug: return f"{brand_slug}-{campaign_slug}-{uid}.{ext}" return f"{brand_slug}-{uid}.{ext}" def build_custom_filename_without_ext( brand_name: Optional[str], campaign_title: Optional[str], ) -> str: brand_slug = slugify(brand_name) if brand_name else "brand" campaign_slug = slugify(campaign_title) if campaign_title else None if campaign_slug: return f"{brand_slug}-{campaign_slug}" return f"{brand_slug}" def is_platform_api_success(status_code: int) -> bool: return 200 <= status_code < 300 def truncate_strings(obj: Any, max_len: int = 500) -> Any: """ Recursively truncate strings longer than max_len in any nested structure. """ if isinstance(obj, str): return obj[:max_len] + "..." if len(obj) > max_len else obj elif isinstance(obj, list): return [truncate_strings(item, max_len) for item in obj] elif isinstance(obj, dict): return {key: truncate_strings(value, max_len) for key, value in obj.items()} elif isinstance(obj, tuple): return tuple(truncate_strings(item, max_len) for item in obj) elif isinstance(obj, set): return {truncate_strings(item, max_len) for item in obj} return obj def safe_log_dict( data: Dict[str, Any], truncate_keys: Iterable[str], ) -> str: """ Returns a JSON-safe version of a dict for logging, truncating values for specified keys. Args: data (dict): The dictionary to sanitize truncate_keys (Iterable[str]): Keys whose values should be truncated Returns: str: JSON string with sensitive fields truncated """ safe_data = copy.deepcopy(data) for key in set(truncate_keys): if key in safe_data: val = safe_data[key] if isinstance(val, str): safe_data[key] = f"[base64 omitted, len={len(val)}]" elif isinstance(val, list): safe_data[key] = f"[list of {len(val)} base64 items omitted]" else: safe_data[key] = "[omitted]" truncated_obj = truncate_strings(safe_data) return json.dumps(truncated_obj, indent=2) def fire_and_forget(coro: Coroutine) -> None: """ Fire-and-forget utility for async operations that should not block. This is particularly useful for logging operations where we don't want to wait for the log update to complete before continuing processing. Args: coro: The coroutine to execute in the background """ def callback(task: asyncio.Task) -> None: """Callback to handle task completion and log any errors.""" try: task.result() except Exception as e: logger.error( f"Fire-and-forget task failed: {str(e)}", exc_info=True) # Create task and add callback task = asyncio.create_task(coro) task.add_done_callback(callback) def format_key(key: str) -> str: """Format snake_case key to Title Case with spaces. Example: target_audience -> Target Audience """ return " ".join(word.capitalize() for word in key.split("_")) def format_dict_to_context(data: Dict[str, Any], prefix: str = "") -> List[str]: """Convert dictionary to formatted context strings. Args: data: Dictionary to format prefix: Optional prefix for nested keys Returns: List of formatted strings like "Key: value" """ context_parts = [] for key, value in data.items(): if not value: continue formatted_key = format_key(key) if prefix: formatted_key = f"{prefix} {formatted_key}" if isinstance(value, dict): # Format nested dicts as JSON for clean representation context_parts.append(f"{formatted_key}: {json.dumps(value)}") elif isinstance(value, list): if value and isinstance(value[0], str): context_parts.append(f"{formatted_key}: {', '.join(value)}") else: context_parts.append(f"{formatted_key}: {json.dumps(value)}") else: context_parts.append(f"{formatted_key}: {value}") return context_parts