""" AWOOOI — Image Analysis Service (Phase 34, ADR-067) =================================================== 使用 llava:latest 分析 Telegram 傳入的圖片 觸發: - Telegram 傳圖 (photo attachment) 自動觸發 - /screenshot 指令 限制: - 圖片 < 5MB - MIME 驗證 (image/jpeg, image/png, image/webp) - 每 chat_id 每分鐘最多 3 次 (Redis rate limit) 安全: - 暫存到 /tmp/tg_photo_{file_id}.jpg - finally 清理暫存檔 2026-04-10 Claude Sonnet 4.6 Asia/Taipei """ from __future__ import annotations import base64 import time from pathlib import Path from typing import TYPE_CHECKING import httpx import structlog from src.services.model_registry import get_model from src.services.ollama_endpoint_resolver import resolve_ollama_order if TYPE_CHECKING: pass logger = structlog.get_logger(__name__) # D1 集中化 2026-04-11: 從 models.json providers.ollama.models.image_analysis 讀取 _MODEL = get_model("ollama", "image_analysis") _TIMEOUT_S = 120.0 _MAX_SIZE_BYTES = 5 * 1024 * 1024 # 5MB _ALLOWED_MIME = {"image/jpeg", "image/png", "image/webp", "image/gif"} _RATE_LIMIT = 3 # 每 chat_id 每分鐘最多 3 次 _RATE_WINDOW = 60 # 秒 class ImageAnalysisService: """Telegram 圖片分析服務""" def __init__(self) -> None: self._http: httpx.AsyncClient | None = None async def _get_http(self) -> httpx.AsyncClient: if self._http is None or self._http.is_closed: self._http = httpx.AsyncClient(timeout=httpx.Timeout(_TIMEOUT_S, connect=10.0)) return self._http async def analyze( self, chat_id: str, file_id: str, file_bytes: bytes, mime_type: str = "image/jpeg", question: str = "請用繁體中文描述這張圖片,如果是截圖或介面請分析其內容和問題", ) -> str | None: """ 分析圖片,回傳繁中說明 """ # Rate limit 檢查 if not await self._check_rate_limit(chat_id): return "⚠️ 圖片分析頻率超限(每分鐘最多 3 次),請稍後再試" # 大小檢查 if len(file_bytes) > _MAX_SIZE_BYTES: return f"⚠️ 圖片過大({len(file_bytes)//1024}KB),限制 5MB" # MIME 檢查 if mime_type not in _ALLOWED_MIME: return f"⚠️ 不支援的圖片格式:{mime_type}" tmp_path = Path(f"/tmp/tg_photo_{file_id}.jpg") try: tmp_path.write_bytes(file_bytes) result = await self._call_llava(tmp_path, question) if result: logger.info("image_analysis_done", chat_id=chat_id, file_id=file_id[:8]) return result finally: try: tmp_path.unlink(missing_ok=True) except Exception: pass async def _check_rate_limit(self, chat_id: str) -> bool: """Redis sliding window rate limit""" try: from src.core.redis_client import get_redis redis = await get_redis() if redis is None: return True # Redis 不可用時放行 key = f"img_ratelimit:{chat_id}" now = int(time.time()) window_start = now - _RATE_WINDOW pipe = redis.pipeline() pipe.zremrangebyscore(key, 0, window_start) pipe.zcard(key) pipe.zadd(key, {str(now): now}) pipe.expire(key, _RATE_WINDOW * 2) results = await pipe.execute() count = results[1] return count < _RATE_LIMIT except Exception: return True # 失敗時放行 async def _call_llava(self, image_path: Path, question: str) -> str | None: """呼叫 llava:latest via Ollama /api/generate with base64 image""" timed_out = False try: image_b64 = base64.b64encode(image_path.read_bytes()).decode() http = await self._get_http() for endpoint in resolve_ollama_order("image_analysis"): if not endpoint.url: continue try: resp = await http.post( f"{endpoint.url}/api/generate", json={ "model": _MODEL, "prompt": question, "images": [image_b64], "stream": False, "options": { "num_predict": 512, "temperature": 0.3, }, }, ) if resp.status_code == 200: text = resp.json().get("response", "").strip() return text or None logger.warning( "image_analysis_ollama_error", provider=endpoint.provider_name, status=resp.status_code, ) except httpx.TimeoutException: timed_out = True logger.warning( "image_analysis_timeout", provider=endpoint.provider_name, path=str(image_path), ) except Exception as e: logger.error( "image_analysis_failed", provider=endpoint.provider_name, error=str(e), ) if timed_out: return "⚠️ 圖片分析超時(llava 處理中),請稍後重試" return None except Exception as e: logger.error("image_analysis_failed", error=str(e)) return None async def download_and_analyze( self, chat_id: str, file_id: str, question: str = "請用繁體中文描述這張圖片,如果是截圖或介面請分析其內容和問題", ) -> None: """ 從 Telegram 下載圖片後分析並發送結果 供 telegram webhook handler 呼叫(含 download + analyze + send) """ try: file_bytes = await self._download_telegram_file(file_id) if not file_bytes: return result = await self.analyze( chat_id=chat_id, file_id=file_id, file_bytes=file_bytes, question=question, ) if result: # Phase 34: OpenClaw 在 SRE 群組回覆圖片分析結果(llava vision) from src.services.telegram_gateway import get_telegram_gateway tg = get_telegram_gateway() await tg.initialize() await tg.send_as_openclaw(f"🖼️ 圖片分析\n{result}") except Exception as e: logger.warning("download_and_analyze_failed", error=str(e)) async def _download_telegram_file(self, file_id: str) -> bytes | None: """透過 Telegram Bot API 下載圖片""" try: from src.core.config import get_settings cfg = get_settings() # Phase 34: polling 用 OPENCLAW_TG_BOT_TOKEN 下載圖片 bot_token = cfg.OPENCLAW_TG_BOT_TOKEN or cfg.TELEGRAM_BOT_TOKEN http = await self._get_http() # Step 1: getFile → file_path get_file_resp = await http.get( f"https://api.telegram.org/bot{bot_token}/getFile", params={"file_id": file_id}, timeout=httpx.Timeout(15.0, connect=5.0), ) if get_file_resp.status_code != 200: logger.warning("tg_getfile_failed", status=get_file_resp.status_code) return None file_path = get_file_resp.json().get("result", {}).get("file_path") if not file_path: return None # Step 2: 下載檔案 dl_resp = await http.get( f"https://api.telegram.org/file/bot{bot_token}/{file_path}", timeout=httpx.Timeout(30.0, connect=5.0), ) if dl_resp.status_code == 200: return dl_resp.content except Exception as e: logger.warning("tg_download_failed", file_id=file_id[:8], error=str(e)) return None async def close(self) -> None: if self._http and not self._http.is_closed: await self._http.aclose() _instance: ImageAnalysisService | None = None def get_image_analysis_service() -> ImageAnalysisService: global _instance if _instance is None: _instance = ImageAnalysisService() return _instance def set_image_analysis_service(svc: ImageAnalysisService) -> None: global _instance _instance = svc