新增Slack适配器全套核心模块,包括消息处理流水线、会话管理、配置适配、权限控制等完整功能: 1. 新增语音、视觉相关的TTS和图像分析导出接口 2. 实现消息预处理、路由、线程上下文处理的完整流水线 3. 新增账号管理、缓存机制、房间上下文提取功能 4. 支持Webhook和Socket Mode两种事件接收方式 5. 实现权限白名单、审批配对、自动状态管理功能 6. 新增配置迁移、作用域校验、重连策略等辅助模块
227 lines
8.2 KiB
Python
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))
|