from __future__ import annotations import base64 import logging from dataclasses import dataclass, field from typing import Any logger = logging.getLogger(__name__) @dataclass class SlackVisionConfig: enabled: bool = False provider: str = "openai" model: str = "gpt-4o" api_url: str = "" api_key: str = "" max_tokens: int = 300 system_prompt: str = "You are a helpful assistant. Analyze the image and provide a detailed description." auto_analyze: bool = False supported_mimetypes: list[str] = field( default_factory=lambda: ["image/png", "image/jpeg", "image/gif", "image/webp"] ) @classmethod def from_config(cls, config: dict[str, Any] | None) -> SlackVisionConfig: if not config: return cls() vision_cfg = config.get("vision", {}) or {} return cls( enabled=vision_cfg.get("enabled", False), provider=vision_cfg.get("provider", "openai"), model=vision_cfg.get("model", "gpt-4o"), api_url=vision_cfg.get("api_url", ""), api_key=vision_cfg.get("api_key", ""), max_tokens=vision_cfg.get("max_tokens", 300), system_prompt=vision_cfg.get( "system_prompt", "You are a helpful assistant. Analyze the image and provide a detailed description." ), auto_analyze=vision_cfg.get("auto_analyze", False), supported_mimetypes=vision_cfg.get( "supported_mimetypes", ["image/png", "image/jpeg", "image/gif", "image/webp"] ), ) @dataclass class VisionResult: description: str provider: str model: str success: bool = True error: str = "" async def analyze_slack_image( image_url: str, prompt: str = "", *, vision_config: SlackVisionConfig | None = None, slack_token: str = "", ) -> VisionResult: cfg = vision_config or SlackVisionConfig() if not cfg.enabled: return VisionResult(description="", provider="", model="", success=False, error="Vision not enabled") try: image_data = await _download_image(image_url, slack_token) if not image_data: return VisionResult(description="", provider="", model="", success=False, error="Failed to download image") mime_type = _detect_mime_type(image_data) if cfg.supported_mimetypes and mime_type not in cfg.supported_mimetypes: return VisionResult( description="", provider=cfg.provider, model=cfg.model, success=False, error=f"Unsupported mime type: {mime_type}", ) image_b64 = base64.b64encode(image_data).decode() if cfg.provider == "openai": return await _analyze_openai(image_b64, mime_type, prompt, cfg) elif cfg.provider == "gemini": return await _analyze_gemini(image_b64, mime_type, prompt, cfg) else: logger.warning(f"[SlackVision] Unknown vision provider: {cfg.provider}") return VisionResult( description="", provider=cfg.provider, model=cfg.model, success=False, error=f"Unknown provider: {cfg.provider}", ) except Exception as e: logger.error(f"[SlackVision] Analysis failed: {e}") return VisionResult(description="", provider=cfg.provider, model=cfg.model, success=False, error=str(e)) async def _download_image(image_url: str, slack_token: str) -> bytes | None: import aiohttp headers = {} if slack_token: headers["Authorization"] = f"Bearer {slack_token}" try: async with aiohttp.ClientSession() as session: async with session.get(image_url, headers=headers, timeout=aiohttp.ClientTimeout(total=30)) as resp: if resp.status == 200: return await resp.read() logger.warning(f"[SlackVision] Failed to download image: HTTP {resp.status}") return None except Exception as e: logger.warning(f"[SlackVision] Image download failed: {e}") return None def _detect_mime_type(data: bytes) -> str: if data.startswith(b"\x89PNG"): return "image/png" if data.startswith(b"\xff\xd8"): return "image/jpeg" if data.startswith(b"GIF8"): return "image/gif" if data.startswith(b"RIFF") and data[8:12] == b"WEBP": return "image/webp" return "application/octet-stream" async def _analyze_openai(image_b64: str, mime_type: str, prompt: str, cfg: SlackVisionConfig) -> VisionResult: import aiohttp api_url = cfg.api_url or "https://api.openai.com/v1/chat/completions" api_key = cfg.api_key if not api_key: return VisionResult( description="", provider=cfg.provider, model=cfg.model, success=False, error="API key not configured" ) user_content: list[dict[str, Any]] = [ {"type": "image_url", "image_url": {"url": f"data:{mime_type};base64,{image_b64}"}} ] if prompt: user_content.insert(0, {"type": "text", "text": prompt}) else: user_content.insert(0, {"type": "text", "text": "Please describe this image in detail."}) payload = { "model": cfg.model, "messages": [ {"role": "system", "content": cfg.system_prompt}, {"role": "user", "content": user_content}, ], "max_tokens": cfg.max_tokens, } headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"} try: async with aiohttp.ClientSession() as session: async with session.post( api_url, json=payload, headers=headers, timeout=aiohttp.ClientTimeout(total=60) ) as resp: if resp.status != 200: return VisionResult( description="", provider=cfg.provider, model=cfg.model, success=False, error=f"API returned status {resp.status}", ) data = await resp.json() description = data.get("choices", [{}])[0].get("message", {}).get("content", "") return VisionResult(description=description, provider=cfg.provider, model=cfg.model) except Exception as e: return VisionResult(description="", provider=cfg.provider, model=cfg.model, success=False, error=str(e)) async def _analyze_gemini(image_b64: str, mime_type: str, prompt: str, cfg: SlackVisionConfig) -> VisionResult: import aiohttp api_key = cfg.api_key if not api_key: return VisionResult( description="", provider=cfg.provider, model=cfg.model, success=False, error="API key not configured" ) api_url = ( cfg.api_url or f"https://generativelanguage.googleapis.com/v1beta/models/{cfg.model}:generateContent?key={api_key}" ) payload = { "contents": [ { "parts": [ {"text": prompt or "Describe this image in detail."}, {"inline_data": {"mime_type": mime_type, "data": image_b64}}, ] } ] } try: async with aiohttp.ClientSession() as session: async with session.post(api_url, json=payload, timeout=aiohttp.ClientTimeout(total=60)) as resp: if resp.status != 200: return VisionResult( description="", provider=cfg.provider, model=cfg.model, success=False, error=f"API returned status {resp.status}", ) data = await resp.json() description = "" candidates = data.get("candidates", []) if candidates: parts = candidates[0].get("content", {}).get("parts", []) if parts: description = parts[0].get("text", "") return VisionResult(description=description, provider=cfg.provider, model=cfg.model) except Exception as e: return VisionResult(description="", provider=cfg.provider, model=cfg.model, success=False, error=str(e))