feat: 人参果/人参果1号 飞书图片解析支持
在飞书 WS handler 中新增图片消息识别与下载:
- _get_image_key: 检测飞书 image 类型消息,提取 image_key
- download_image_from_feishu: 调用飞书 API 下载图片二进制
- 图片保存到 agent_workspaces/{agent_id}/images/ 下
- Agent 自动调用 image_ocr + image_vision 分析后回复用户
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -87,6 +87,32 @@ def send_message_to_user(
|
||||
return False
|
||||
|
||||
|
||||
def download_image_from_feishu(message_id: str, image_key: str) -> Optional[bytes]:
|
||||
"""从飞书下载图片内容(使用人参果1号应用凭证)。"""
|
||||
token = _get_tenant_access_token()
|
||||
if not token:
|
||||
return None
|
||||
try:
|
||||
with httpx.Client(timeout=30) as client:
|
||||
resp = client.get(
|
||||
f"https://open.feishu.cn/open-apis/im/v1/messages/{message_id}/resources/{image_key}",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
params={"type": "image"},
|
||||
)
|
||||
if resp.is_success and resp.content:
|
||||
logger.info("人参果1号 飞书图片下载成功: image_key=%s size=%d",
|
||||
image_key[:20], len(resp.content))
|
||||
return resp.content
|
||||
else:
|
||||
result = resp.json() if resp.content else {}
|
||||
logger.warning("人参果1号 图片下载失败: code=%s msg=%s",
|
||||
result.get("code"), result.get("msg"))
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.warning("人参果1号 图片下载异常: %s", e)
|
||||
return None
|
||||
|
||||
|
||||
def send_plain_text(open_id: str, text: str) -> bool:
|
||||
token = _get_tenant_access_token()
|
||||
if not token:
|
||||
|
||||
@@ -44,6 +44,25 @@ def _get_message_text(data) -> Optional[str]:
|
||||
return None
|
||||
|
||||
|
||||
def _get_image_key(data) -> Optional[str]:
|
||||
"""从图片消息中提取 image_key,返回 None 表示不是图片消息。"""
|
||||
try:
|
||||
ev = data.event
|
||||
msg = getattr(ev, "message", None)
|
||||
if not msg:
|
||||
return None
|
||||
msg_type = getattr(msg, "message_type", "")
|
||||
if msg_type != "image":
|
||||
return None
|
||||
content_str = getattr(msg, "content", None)
|
||||
if not content_str:
|
||||
return None
|
||||
parsed = json.loads(content_str)
|
||||
return parsed.get("image_key", None)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _get_sender_open_id(data) -> Optional[str]:
|
||||
try:
|
||||
ev = data.event
|
||||
@@ -127,13 +146,17 @@ async def _handle_message_async(data):
|
||||
union_id = _get_sender_union_id(data)
|
||||
chat_type = _get_chat_type(data)
|
||||
text = _get_message_text(data)
|
||||
image_key = _get_image_key(data)
|
||||
message_id = _get_message_id(data)
|
||||
|
||||
if not open_id or chat_type != "p2p":
|
||||
return
|
||||
|
||||
logger.info("人参果1号收到消息: open_id=%s text=%s", open_id[:20], text[:50] if text else "(空)")
|
||||
|
||||
if not text:
|
||||
if text:
|
||||
logger.info("人参果1号收到文本消息: open_id=%s text=%s", open_id[:20], text[:80])
|
||||
elif image_key and message_id:
|
||||
logger.info("人参果1号收到图片消息: open_id=%s image_key=%s", open_id[:20], image_key[:30])
|
||||
else:
|
||||
return
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -163,6 +186,34 @@ async def _handle_message_async(data):
|
||||
|
||||
_reply_to_feishu(open_id, "正在思考,请稍候...")
|
||||
|
||||
# ─── 图片消息:下载飞书图片到工作区 ───────────────────
|
||||
image_attachment_path = None
|
||||
if image_key and message_id and not text:
|
||||
from app.services.renshenguo2_app_service import download_image_from_feishu
|
||||
from app.services.builtin_tools import _local_file_workspace_root
|
||||
|
||||
image_bytes = download_image_from_feishu(message_id, image_key)
|
||||
if image_bytes:
|
||||
ws_root = _local_file_workspace_root()
|
||||
uploads_dir = ws_root / "agent_workspaces" / str(agent.id) / "images"
|
||||
uploads_dir.mkdir(parents=True, exist_ok=True)
|
||||
import time as _time
|
||||
image_filename = f"feishu_img_{_time.strftime('%Y%m%d_%H%M%S')}.jpg"
|
||||
image_path = uploads_dir / image_filename
|
||||
image_path.write_bytes(image_bytes)
|
||||
image_attachment_path = str(image_path.relative_to(ws_root)).replace("\\", "/")
|
||||
text = (
|
||||
f"用户通过飞书发来了一张图片。"
|
||||
f"图片已保存到工作区路径: {image_attachment_path}\n"
|
||||
f"请先用 image_ocr 提取图片中的文字,"
|
||||
f"如果文字不足以理解内容,再用 image_vision 分析图片,"
|
||||
f"然后根据分析结果回复用户。"
|
||||
)
|
||||
logger.info("人参果1号图片已保存: %s (%d bytes)", image_attachment_path, len(image_bytes))
|
||||
else:
|
||||
_reply_to_feishu(open_id, "图片下载失败,请稍后重试。")
|
||||
return
|
||||
|
||||
from app.agent_runtime import AgentRuntime, AgentConfig, AgentLLMConfig, AgentToolConfig, AgentMemoryConfig
|
||||
|
||||
wc = agent.workflow_config or {}
|
||||
|
||||
@@ -87,6 +87,40 @@ def send_message_to_user(
|
||||
return False
|
||||
|
||||
|
||||
def download_image_from_feishu(message_id: str, image_key: str) -> Optional[bytes]:
|
||||
"""从飞书下载图片内容。
|
||||
|
||||
Args:
|
||||
message_id: 飞书消息 ID
|
||||
image_key: 图片 key(来自消息 content 中的 image_key)
|
||||
|
||||
Returns:
|
||||
图片二进制数据,失败返回 None
|
||||
"""
|
||||
token = _get_tenant_access_token()
|
||||
if not token:
|
||||
return None
|
||||
try:
|
||||
with httpx.Client(timeout=30) as client:
|
||||
resp = client.get(
|
||||
f"https://open.feishu.cn/open-apis/im/v1/messages/{message_id}/resources/{image_key}",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
params={"type": "image"},
|
||||
)
|
||||
if resp.is_success and resp.content:
|
||||
logger.info("飞书图片下载成功: message_id=%s image_key=%s size=%d",
|
||||
message_id, image_key[:20], len(resp.content))
|
||||
return resp.content
|
||||
else:
|
||||
result = resp.json() if resp.content else {}
|
||||
logger.warning("飞书图片下载失败: code=%s msg=%s",
|
||||
result.get("code"), result.get("msg"))
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.warning("飞书图片下载异常: %s", e)
|
||||
return None
|
||||
|
||||
|
||||
def send_plain_text(open_id: str, text: str) -> bool:
|
||||
token = _get_tenant_access_token()
|
||||
if not token:
|
||||
|
||||
@@ -25,6 +25,15 @@ def _get_message_id(data) -> Optional[str]:
|
||||
return None
|
||||
|
||||
|
||||
def _get_message_id(data) -> Optional[str]:
|
||||
try:
|
||||
ev = data.event
|
||||
msg = getattr(ev, "message", None)
|
||||
return getattr(msg, "message_id", None) if msg else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _get_message_text(data) -> Optional[str]:
|
||||
try:
|
||||
ev = data.event
|
||||
@@ -44,6 +53,25 @@ def _get_message_text(data) -> Optional[str]:
|
||||
return None
|
||||
|
||||
|
||||
def _get_image_key(data) -> Optional[str]:
|
||||
"""从图片消息中提取 image_key,返回 None 表示不是图片消息。"""
|
||||
try:
|
||||
ev = data.event
|
||||
msg = getattr(ev, "message", None)
|
||||
if not msg:
|
||||
return None
|
||||
msg_type = getattr(msg, "message_type", "")
|
||||
if msg_type != "image":
|
||||
return None
|
||||
content_str = getattr(msg, "content", None)
|
||||
if not content_str:
|
||||
return None
|
||||
parsed = json.loads(content_str)
|
||||
return parsed.get("image_key", None)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _get_sender_open_id(data) -> Optional[str]:
|
||||
try:
|
||||
ev = data.event
|
||||
@@ -127,13 +155,17 @@ async def _handle_message_async(data):
|
||||
union_id = _get_sender_union_id(data)
|
||||
chat_type = _get_chat_type(data)
|
||||
text = _get_message_text(data)
|
||||
image_key = _get_image_key(data)
|
||||
message_id = _get_message_id(data)
|
||||
|
||||
if not open_id or chat_type != "p2p":
|
||||
return
|
||||
|
||||
logger.info("人参果收到消息: open_id=%s text=%s", open_id[:20], text[:50] if text else "(空)")
|
||||
|
||||
if not text:
|
||||
if text:
|
||||
logger.info("人参果收到文本消息: open_id=%s text=%s", open_id[:20], text[:80])
|
||||
elif image_key and message_id:
|
||||
logger.info("人参果收到图片消息: open_id=%s image_key=%s", open_id[:20], image_key[:30])
|
||||
else:
|
||||
return
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -163,6 +195,34 @@ async def _handle_message_async(data):
|
||||
|
||||
_reply_to_feishu(open_id, "正在思考,请稍候...")
|
||||
|
||||
# ─── 图片消息:下载飞书图片到工作区 ───────────────────
|
||||
image_attachment_path: Optional[str] = None
|
||||
if image_key and message_id and not text:
|
||||
from app.services.renshenguo_app_service import download_image_from_feishu
|
||||
from app.services.builtin_tools import _local_file_workspace_root, _sanitize_tool_path_string
|
||||
|
||||
image_bytes = download_image_from_feishu(message_id, image_key)
|
||||
if image_bytes:
|
||||
ws_root = _local_file_workspace_root()
|
||||
uploads_dir = ws_root / "agent_workspaces" / str(agent.id) / "images"
|
||||
uploads_dir.mkdir(parents=True, exist_ok=True)
|
||||
import time as _time
|
||||
image_filename = f"feishu_img_{_time.strftime('%Y%m%d_%H%M%S')}.jpg"
|
||||
image_path = uploads_dir / image_filename
|
||||
image_path.write_bytes(image_bytes)
|
||||
image_attachment_path = str(image_path.relative_to(ws_root)).replace("\\", "/")
|
||||
text = (
|
||||
f"用户通过飞书发来了一张图片。"
|
||||
f"图片已保存到工作区路径: {image_attachment_path}\n"
|
||||
f"请先用 image_ocr 提取图片中的文字,"
|
||||
f"如果文字不足以理解内容,再用 image_vision 分析图片,"
|
||||
f"然后根据分析结果回复用户。"
|
||||
)
|
||||
logger.info("人参果图片已保存: %s (%d bytes)", image_attachment_path, len(image_bytes))
|
||||
else:
|
||||
_reply_to_feishu(open_id, "图片下载失败,请稍后重试。")
|
||||
return
|
||||
|
||||
from app.agent_runtime import AgentRuntime, AgentConfig, AgentLLMConfig, AgentToolConfig, AgentMemoryConfig
|
||||
|
||||
wc = agent.workflow_config or {}
|
||||
|
||||
Reference in New Issue
Block a user