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:
renjianbo
2026-05-07 08:02:14 +08:00
parent 5b5eb84dfb
commit 66d52ad020
4 changed files with 177 additions and 6 deletions

View File

@@ -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:

View File

@@ -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 {}

View File

@@ -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:

View File

@@ -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 {}