ForcePilot/backend/package/yuxi/channels/adapters/slack/vision/vision.py
Kris a2aa782b86 feat(slack adapter): 实现完整的Slack频道适配器基础功能
新增Slack适配器全套核心模块,包括消息处理流水线、会话管理、配置适配、权限控制等完整功能:
1. 新增语音、视觉相关的TTS和图像分析导出接口
2. 实现消息预处理、路由、线程上下文处理的完整流水线
3. 新增账号管理、缓存机制、房间上下文提取功能
4. 支持Webhook和Socket Mode两种事件接收方式
5. 实现权限白名单、审批配对、自动状态管理功能
6. 新增配置迁移、作用域校验、重连策略等辅助模块
2026-05-12 00:48:57 +08:00

227 lines
8.2 KiB
Python

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))