mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-03-19 13:28:11 +08:00
feat: add skills and upgrade feishu/dingtalk channel
This commit is contained in:
@@ -64,15 +64,22 @@ class ChatChannel(Channel):
|
||||
check_contain(group_name, group_name_keyword_white_list),
|
||||
]
|
||||
):
|
||||
group_chat_in_one_session = conf().get("group_chat_in_one_session", [])
|
||||
session_id = cmsg.actual_user_id
|
||||
if any(
|
||||
[
|
||||
group_name in group_chat_in_one_session,
|
||||
"ALL_GROUP" in group_chat_in_one_session,
|
||||
]
|
||||
):
|
||||
# Check global group_shared_session config first
|
||||
group_shared_session = conf().get("group_shared_session", True)
|
||||
if group_shared_session:
|
||||
# All users in the group share the same session
|
||||
session_id = group_id
|
||||
else:
|
||||
# Check group-specific whitelist (legacy behavior)
|
||||
group_chat_in_one_session = conf().get("group_chat_in_one_session", [])
|
||||
session_id = cmsg.actual_user_id
|
||||
if any(
|
||||
[
|
||||
group_name in group_chat_in_one_session,
|
||||
"ALL_GROUP" in group_chat_in_one_session,
|
||||
]
|
||||
):
|
||||
session_id = group_id
|
||||
else:
|
||||
logger.debug(f"No need reply, groupName not in whitelist, group_name={group_name}")
|
||||
return None
|
||||
@@ -283,7 +290,98 @@ class ChatChannel(Channel):
|
||||
reply = e_context["reply"]
|
||||
if not e_context.is_pass() and reply and reply.type:
|
||||
logger.debug("[chat_channel] ready to send reply: {}, context: {}".format(reply, context))
|
||||
self._send(reply, context)
|
||||
|
||||
# 如果是文本回复,尝试提取并发送图片
|
||||
if reply.type == ReplyType.TEXT:
|
||||
self._extract_and_send_images(reply, context)
|
||||
# 如果是图片回复但带有文本内容,先发文本再发图片
|
||||
elif reply.type == ReplyType.IMAGE_URL and hasattr(reply, 'text_content') and reply.text_content:
|
||||
# 先发送文本
|
||||
text_reply = Reply(ReplyType.TEXT, reply.text_content)
|
||||
self._send(text_reply, context)
|
||||
# 短暂延迟后发送图片
|
||||
time.sleep(0.3)
|
||||
self._send(reply, context)
|
||||
else:
|
||||
self._send(reply, context)
|
||||
|
||||
def _extract_and_send_images(self, reply: Reply, context: Context):
|
||||
"""
|
||||
从文本回复中提取图片/视频URL并单独发送
|
||||
支持格式:[图片: /path/to/image.png], [视频: /path/to/video.mp4], , <img src="url">
|
||||
最多发送5个媒体文件
|
||||
"""
|
||||
content = reply.content
|
||||
media_items = [] # [(url, type), ...]
|
||||
|
||||
# 正则提取各种格式的媒体URL
|
||||
patterns = [
|
||||
(r'\[图片:\s*([^\]]+)\]', 'image'), # [图片: /path/to/image.png]
|
||||
(r'\[视频:\s*([^\]]+)\]', 'video'), # [视频: /path/to/video.mp4]
|
||||
(r'!\[.*?\]\(([^\)]+)\)', 'image'), #  - 默认图片
|
||||
(r'<img[^>]+src=["\']([^"\']+)["\']', 'image'), # <img src="url">
|
||||
(r'<video[^>]+src=["\']([^"\']+)["\']', 'video'), # <video src="url">
|
||||
(r'https?://[^\s]+\.(?:jpg|jpeg|png|gif|webp)', 'image'), # 直接的图片URL
|
||||
(r'https?://[^\s]+\.(?:mp4|avi|mov|wmv|flv)', 'video'), # 直接的视频URL
|
||||
]
|
||||
|
||||
for pattern, media_type in patterns:
|
||||
matches = re.findall(pattern, content, re.IGNORECASE)
|
||||
for match in matches:
|
||||
media_items.append((match, media_type))
|
||||
|
||||
# 去重(保持顺序)并限制最多5个
|
||||
seen = set()
|
||||
unique_items = []
|
||||
for url, mtype in media_items:
|
||||
if url not in seen:
|
||||
seen.add(url)
|
||||
unique_items.append((url, mtype))
|
||||
media_items = unique_items[:5]
|
||||
|
||||
if media_items:
|
||||
logger.info(f"[chat_channel] Extracted {len(media_items)} media item(s) from reply")
|
||||
|
||||
# 先发送文本(保持原文本不变)
|
||||
self._send(reply, context)
|
||||
|
||||
# 然后逐个发送媒体文件
|
||||
for i, (url, media_type) in enumerate(media_items):
|
||||
try:
|
||||
# 判断是本地文件还是URL
|
||||
if url.startswith(('http://', 'https://')):
|
||||
# 网络资源
|
||||
if media_type == 'video':
|
||||
# 视频使用 FILE 类型发送
|
||||
media_reply = Reply(ReplyType.FILE, url)
|
||||
media_reply.file_name = os.path.basename(url)
|
||||
else:
|
||||
# 图片使用 IMAGE_URL 类型
|
||||
media_reply = Reply(ReplyType.IMAGE_URL, url)
|
||||
elif os.path.exists(url):
|
||||
# 本地文件
|
||||
if media_type == 'video':
|
||||
# 视频使用 FILE 类型,转换为 file:// URL
|
||||
media_reply = Reply(ReplyType.FILE, f"file://{url}")
|
||||
media_reply.file_name = os.path.basename(url)
|
||||
else:
|
||||
# 图片使用 IMAGE_URL 类型,转换为 file:// URL
|
||||
media_reply = Reply(ReplyType.IMAGE_URL, f"file://{url}")
|
||||
else:
|
||||
logger.warning(f"[chat_channel] Media file not found or invalid URL: {url}")
|
||||
continue
|
||||
|
||||
# 发送媒体文件(添加小延迟避免频率限制)
|
||||
if i > 0:
|
||||
time.sleep(0.5)
|
||||
self._send(media_reply, context)
|
||||
logger.info(f"[chat_channel] Sent {media_type} {i+1}/{len(media_items)}: {url[:50]}...")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[chat_channel] Failed to send {media_type} {url}: {e}")
|
||||
else:
|
||||
# 没有媒体文件,正常发送文本
|
||||
self._send(reply, context)
|
||||
|
||||
def _send(self, reply: Reply, context: Context, retry_cnt=0):
|
||||
try:
|
||||
|
||||
@@ -9,6 +9,7 @@ import json
|
||||
# -*- coding=utf-8 -*-
|
||||
import logging
|
||||
import time
|
||||
import requests
|
||||
|
||||
import dingtalk_stream
|
||||
from dingtalk_stream import AckMessage
|
||||
@@ -107,16 +108,156 @@ class DingTalkChanel(ChatChannel, dingtalk_stream.ChatbotHandler):
|
||||
conf()["group_name_white_list"] = ["ALL_GROUP"]
|
||||
# 单聊无需前缀
|
||||
conf()["single_chat_prefix"] = [""]
|
||||
# Access token cache
|
||||
self._access_token = None
|
||||
self._access_token_expires_at = 0
|
||||
# Robot code cache (extracted from incoming messages)
|
||||
self._robot_code = None
|
||||
|
||||
def startup(self):
|
||||
credential = dingtalk_stream.Credential(self.dingtalk_client_id, self.dingtalk_client_secret)
|
||||
client = dingtalk_stream.DingTalkStreamClient(credential)
|
||||
client.register_callback_handler(dingtalk_stream.chatbot.ChatbotMessage.TOPIC, self)
|
||||
client.start_forever()
|
||||
|
||||
def get_access_token(self):
|
||||
"""
|
||||
获取企业内部应用的 access_token
|
||||
文档: https://open.dingtalk.com/document/orgapp/obtain-orgapp-token
|
||||
"""
|
||||
current_time = time.time()
|
||||
|
||||
# 如果 token 还没过期,直接返回缓存的 token
|
||||
if self._access_token and current_time < self._access_token_expires_at:
|
||||
return self._access_token
|
||||
|
||||
# 获取新的 access_token
|
||||
url = "https://api.dingtalk.com/v1.0/oauth2/accessToken"
|
||||
headers = {"Content-Type": "application/json"}
|
||||
data = {
|
||||
"appKey": self.dingtalk_client_id,
|
||||
"appSecret": self.dingtalk_client_secret
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.post(url, headers=headers, json=data, timeout=10)
|
||||
result = response.json()
|
||||
|
||||
if response.status_code == 200 and "accessToken" in result:
|
||||
self._access_token = result["accessToken"]
|
||||
# Token 有效期为 2 小时,提前 5 分钟刷新
|
||||
self._access_token_expires_at = current_time + result.get("expireIn", 7200) - 300
|
||||
logger.info("[DingTalk] Access token refreshed successfully")
|
||||
return self._access_token
|
||||
else:
|
||||
logger.error(f"[DingTalk] Failed to get access token: {result}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"[DingTalk] Error getting access token: {e}")
|
||||
return None
|
||||
|
||||
def send_single_message(self, user_id: str, content: str, robot_code: str) -> bool:
|
||||
"""
|
||||
Send message to single user (private chat)
|
||||
API: https://open.dingtalk.com/document/orgapp/chatbots-send-one-on-one-chat-messages-in-batches
|
||||
"""
|
||||
access_token = self.get_access_token()
|
||||
if not access_token:
|
||||
logger.error("[DingTalk] Failed to send single message: Access token not available.")
|
||||
return False
|
||||
|
||||
if not robot_code:
|
||||
logger.error("[DingTalk] Cannot send single message: robot_code is required")
|
||||
return False
|
||||
|
||||
url = "https://api.dingtalk.com/v1.0/robot/oToMessages/batchSend"
|
||||
headers = {
|
||||
"x-acs-dingtalk-access-token": access_token,
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
data = {
|
||||
"msgParam": json.dumps({"content": content}),
|
||||
"msgKey": "sampleText",
|
||||
"userIds": [user_id],
|
||||
"robotCode": robot_code
|
||||
}
|
||||
|
||||
logger.info(f"[DingTalk] Sending single message to user {user_id} with robot_code {robot_code}")
|
||||
try:
|
||||
response = requests.post(url, headers=headers, json=data, timeout=10)
|
||||
result = response.json()
|
||||
|
||||
if response.status_code == 200 and result.get("processQueryKey"):
|
||||
logger.info(f"[DingTalk] Single message sent successfully to {user_id}")
|
||||
return True
|
||||
else:
|
||||
logger.error(f"[DingTalk] Failed to send single message: {result}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"[DingTalk] Error sending single message: {e}")
|
||||
return False
|
||||
|
||||
def send_group_message(self, conversation_id: str, content: str, robot_code: str = None):
|
||||
"""
|
||||
主动发送群消息
|
||||
文档: https://open.dingtalk.com/document/orgapp/the-robot-sends-a-group-message
|
||||
|
||||
Args:
|
||||
conversation_id: 会话ID (openConversationId)
|
||||
content: 消息内容
|
||||
robot_code: 机器人编码,默认使用 dingtalk_client_id
|
||||
"""
|
||||
access_token = self.get_access_token()
|
||||
if not access_token:
|
||||
logger.error("[DingTalk] Cannot send group message: no access token")
|
||||
return False
|
||||
|
||||
# Validate robot_code
|
||||
if not robot_code:
|
||||
logger.error("[DingTalk] Cannot send group message: robot_code is required")
|
||||
return False
|
||||
|
||||
url = "https://api.dingtalk.com/v1.0/robot/groupMessages/send"
|
||||
headers = {
|
||||
"x-acs-dingtalk-access-token": access_token,
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
data = {
|
||||
"msgParam": json.dumps({"content": content}),
|
||||
"msgKey": "sampleText",
|
||||
"openConversationId": conversation_id,
|
||||
"robotCode": robot_code
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.post(url, headers=headers, json=data, timeout=10)
|
||||
result = response.json()
|
||||
|
||||
if response.status_code == 200:
|
||||
logger.info(f"[DingTalk] Group message sent successfully to {conversation_id}")
|
||||
return True
|
||||
else:
|
||||
logger.error(f"[DingTalk] Failed to send group message: {result}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"[DingTalk] Error sending group message: {e}")
|
||||
return False
|
||||
|
||||
async def process(self, callback: dingtalk_stream.CallbackMessage):
|
||||
try:
|
||||
incoming_message = dingtalk_stream.ChatbotMessage.from_dict(callback.data)
|
||||
|
||||
# Debug: 打印完整的 event 数据
|
||||
logger.info(f"[DingTalk] ===== Incoming Message Debug =====")
|
||||
logger.info(f"[DingTalk] callback.data keys: {callback.data.keys() if hasattr(callback.data, 'keys') else 'N/A'}")
|
||||
logger.info(f"[DingTalk] incoming_message attributes: {dir(incoming_message)}")
|
||||
logger.info(f"[DingTalk] robot_code: {getattr(incoming_message, 'robot_code', 'N/A')}")
|
||||
logger.info(f"[DingTalk] chatbot_corp_id: {getattr(incoming_message, 'chatbot_corp_id', 'N/A')}")
|
||||
logger.info(f"[DingTalk] chatbot_user_id: {getattr(incoming_message, 'chatbot_user_id', 'N/A')}")
|
||||
logger.info(f"[DingTalk] conversation_id: {getattr(incoming_message, 'conversation_id', 'N/A')}")
|
||||
logger.info(f"[DingTalk] Raw callback.data: {callback.data}")
|
||||
logger.info(f"[DingTalk] =====================================")
|
||||
|
||||
image_download_handler = self # 传入方法所在的类实例
|
||||
dingtalk_msg = DingTalkMessage(incoming_message, image_download_handler)
|
||||
|
||||
@@ -174,8 +315,48 @@ class DingTalkChanel(ChatChannel, dingtalk_stream.ChatbotHandler):
|
||||
|
||||
def send(self, reply: Reply, context: Context):
|
||||
receiver = context["receiver"]
|
||||
isgroup = context.kwargs['msg'].is_group
|
||||
incoming_message = context.kwargs['msg'].incoming_message
|
||||
|
||||
# Check if msg exists (for scheduled tasks, msg might be None)
|
||||
msg = context.kwargs.get('msg')
|
||||
if msg is None:
|
||||
# 定时任务场景:使用主动发送 API
|
||||
is_group = context.get("isgroup", False)
|
||||
logger.info(f"[DingTalk] Sending scheduled task message to {receiver} (is_group={is_group})")
|
||||
|
||||
# 使用缓存的 robot_code 或配置的值
|
||||
robot_code = self._robot_code or conf().get("dingtalk_robot_code")
|
||||
logger.info(f"[DingTalk] Using robot_code: {robot_code}, cached: {self._robot_code}, config: {conf().get('dingtalk_robot_code')}")
|
||||
|
||||
if not robot_code:
|
||||
logger.error(f"[DingTalk] Cannot send scheduled task: robot_code not available. Please send at least one message to the bot first, or configure dingtalk_robot_code in config.json")
|
||||
return
|
||||
|
||||
# 根据是否群聊选择不同的 API
|
||||
if is_group:
|
||||
success = self.send_group_message(receiver, reply.content, robot_code)
|
||||
else:
|
||||
# 单聊场景:尝试从 context 中获取 dingtalk_sender_staff_id
|
||||
sender_staff_id = context.get("dingtalk_sender_staff_id")
|
||||
if not sender_staff_id:
|
||||
logger.error(f"[DingTalk] Cannot send single chat scheduled message: sender_staff_id not available in context")
|
||||
return
|
||||
|
||||
logger.info(f"[DingTalk] Sending single message to staff_id: {sender_staff_id}")
|
||||
success = self.send_single_message(sender_staff_id, reply.content, robot_code)
|
||||
|
||||
if not success:
|
||||
logger.error(f"[DingTalk] Failed to send scheduled task message")
|
||||
return
|
||||
|
||||
# 从正常消息中提取并缓存 robot_code
|
||||
if hasattr(msg, 'robot_code'):
|
||||
robot_code = msg.robot_code
|
||||
if robot_code and robot_code != self._robot_code:
|
||||
self._robot_code = robot_code
|
||||
logger.info(f"[DingTalk] Cached robot_code: {robot_code}")
|
||||
|
||||
isgroup = msg.is_group
|
||||
incoming_message = msg.incoming_message
|
||||
|
||||
if conf().get("dingtalk_card_enabled"):
|
||||
logger.info("[Dingtalk] sendMsg={}, receiver={}".format(reply, receiver))
|
||||
|
||||
@@ -22,6 +22,7 @@ class DingTalkMessage(ChatMessage):
|
||||
self.create_time = event.create_at
|
||||
self.image_content = event.image_content
|
||||
self.rich_text_content = event.rich_text_content
|
||||
self.robot_code = event.robot_code # 机器人编码
|
||||
if event.conversation_type == "1":
|
||||
self.is_group = False
|
||||
else:
|
||||
|
||||
@@ -204,10 +204,36 @@ class FeiShuChanel(ChatChannel):
|
||||
# 图片上传
|
||||
reply_content = self._upload_image_url(reply.content, access_token)
|
||||
if not reply_content:
|
||||
logger.warning("[FeiShu] upload file failed")
|
||||
logger.warning("[FeiShu] upload image failed")
|
||||
return
|
||||
msg_type = "image"
|
||||
content_key = "image_key"
|
||||
elif reply.type == ReplyType.FILE:
|
||||
# 判断是否为视频文件
|
||||
file_path = reply.content
|
||||
if file_path.startswith("file://"):
|
||||
file_path = file_path[7:]
|
||||
|
||||
is_video = file_path.lower().endswith(('.mp4', '.avi', '.mov', '.wmv', '.flv'))
|
||||
|
||||
if is_video:
|
||||
# 视频使用 media 类型
|
||||
file_key = self._upload_video_url(reply.content, access_token)
|
||||
if not file_key:
|
||||
logger.warning("[FeiShu] upload video failed")
|
||||
return
|
||||
reply_content = file_key
|
||||
msg_type = "media"
|
||||
content_key = "file_key"
|
||||
else:
|
||||
# 其他文件使用 file 类型
|
||||
file_key = self._upload_file_url(reply.content, access_token)
|
||||
if not file_key:
|
||||
logger.warning("[FeiShu] upload file failed")
|
||||
return
|
||||
reply_content = file_key
|
||||
msg_type = "file"
|
||||
content_key = "file_key"
|
||||
|
||||
# Check if we can reply to an existing message (need msg_id)
|
||||
can_reply = is_group and msg and hasattr(msg, 'msg_id') and msg.msg_id
|
||||
@@ -260,7 +286,34 @@ class FeiShuChanel(ChatChannel):
|
||||
|
||||
|
||||
def _upload_image_url(self, img_url, access_token):
|
||||
logger.debug(f"[WX] start download image, img_url={img_url}")
|
||||
logger.debug(f"[FeiShu] start process image, img_url={img_url}")
|
||||
|
||||
# Check if it's a local file path (file:// protocol)
|
||||
if img_url.startswith("file://"):
|
||||
local_path = img_url[7:] # Remove "file://" prefix
|
||||
logger.info(f"[FeiShu] uploading local file: {local_path}")
|
||||
|
||||
if not os.path.exists(local_path):
|
||||
logger.error(f"[FeiShu] local file not found: {local_path}")
|
||||
return None
|
||||
|
||||
# Upload directly from local file
|
||||
upload_url = "https://open.feishu.cn/open-apis/im/v1/images"
|
||||
data = {'image_type': 'message'}
|
||||
headers = {'Authorization': f'Bearer {access_token}'}
|
||||
|
||||
with open(local_path, "rb") as file:
|
||||
upload_response = requests.post(upload_url, files={"image": file}, data=data, headers=headers)
|
||||
logger.info(f"[FeiShu] upload file, res={upload_response.content}")
|
||||
|
||||
response_data = upload_response.json()
|
||||
if response_data.get("code") == 0:
|
||||
return response_data.get("data").get("image_key")
|
||||
else:
|
||||
logger.error(f"[FeiShu] upload failed: {response_data}")
|
||||
return None
|
||||
|
||||
# Original logic for HTTP URLs
|
||||
response = requests.get(img_url)
|
||||
suffix = utils.get_path_suffix(img_url)
|
||||
temp_name = str(uuid.uuid4()) + "." + suffix
|
||||
@@ -283,6 +336,207 @@ class FeiShuChanel(ChatChannel):
|
||||
os.remove(temp_name)
|
||||
return upload_response.json().get("data").get("image_key")
|
||||
|
||||
def _upload_video_url(self, video_url, access_token):
|
||||
"""
|
||||
Upload video to Feishu and return file_key (for media type messages)
|
||||
Supports:
|
||||
- file:// URLs for local files
|
||||
- http(s):// URLs (download then upload)
|
||||
"""
|
||||
# For file:// URLs (local files), upload directly
|
||||
if video_url.startswith("file://"):
|
||||
local_path = video_url[7:] # Remove file:// prefix
|
||||
if not os.path.exists(local_path):
|
||||
logger.error(f"[FeiShu] local video file not found: {local_path}")
|
||||
return None
|
||||
|
||||
file_name = os.path.basename(local_path)
|
||||
file_ext = os.path.splitext(file_name)[1].lower()
|
||||
|
||||
# Determine file type for Feishu API (for media messages)
|
||||
# Media type only supports mp4
|
||||
file_type_map = {
|
||||
'.mp4': 'mp4',
|
||||
}
|
||||
file_type = file_type_map.get(file_ext, 'mp4') # Default to mp4
|
||||
|
||||
# Upload video to Feishu (use file upload API, but send as media type)
|
||||
upload_url = "https://open.feishu.cn/open-apis/im/v1/files"
|
||||
data = {'file_type': file_type, 'file_name': file_name}
|
||||
headers = {'Authorization': f'Bearer {access_token}'}
|
||||
|
||||
try:
|
||||
with open(local_path, "rb") as file:
|
||||
upload_response = requests.post(
|
||||
upload_url,
|
||||
files={"file": file},
|
||||
data=data,
|
||||
headers=headers,
|
||||
timeout=(5, 60) # 5s connect, 60s read timeout (videos are larger)
|
||||
)
|
||||
logger.info(f"[FeiShu] upload video response, status={upload_response.status_code}, res={upload_response.content}")
|
||||
|
||||
response_data = upload_response.json()
|
||||
if response_data.get("code") == 0:
|
||||
return response_data.get("data").get("file_key")
|
||||
else:
|
||||
logger.error(f"[FeiShu] upload video failed: {response_data}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"[FeiShu] upload video exception: {e}")
|
||||
return None
|
||||
|
||||
# For HTTP URLs, download first then upload
|
||||
try:
|
||||
logger.info(f"[FeiShu] Downloading video from URL: {video_url}")
|
||||
response = requests.get(video_url, timeout=(5, 60))
|
||||
if response.status_code != 200:
|
||||
logger.error(f"[FeiShu] download video failed, status={response.status_code}")
|
||||
return None
|
||||
|
||||
# Save to temp file
|
||||
import uuid
|
||||
file_name = os.path.basename(video_url) or "video.mp4"
|
||||
temp_name = str(uuid.uuid4()) + "_" + file_name
|
||||
|
||||
with open(temp_name, "wb") as file:
|
||||
file.write(response.content)
|
||||
|
||||
logger.info(f"[FeiShu] Video downloaded, size={len(response.content)} bytes, uploading...")
|
||||
|
||||
# Upload
|
||||
file_ext = os.path.splitext(file_name)[1].lower()
|
||||
file_type_map = {
|
||||
'.mp4': 'mp4',
|
||||
}
|
||||
file_type = file_type_map.get(file_ext, 'mp4')
|
||||
|
||||
upload_url = "https://open.feishu.cn/open-apis/im/v1/files"
|
||||
data = {'file_type': file_type, 'file_name': file_name}
|
||||
headers = {'Authorization': f'Bearer {access_token}'}
|
||||
|
||||
with open(temp_name, "rb") as file:
|
||||
upload_response = requests.post(upload_url, files={"file": file}, data=data, headers=headers, timeout=(5, 60))
|
||||
logger.info(f"[FeiShu] upload video, res={upload_response.content}")
|
||||
|
||||
response_data = upload_response.json()
|
||||
os.remove(temp_name) # Clean up temp file
|
||||
|
||||
if response_data.get("code") == 0:
|
||||
return response_data.get("data").get("file_key")
|
||||
else:
|
||||
logger.error(f"[FeiShu] upload video failed: {response_data}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"[FeiShu] upload video from URL exception: {e}")
|
||||
# Clean up temp file if exists
|
||||
if 'temp_name' in locals() and os.path.exists(temp_name):
|
||||
os.remove(temp_name)
|
||||
return None
|
||||
|
||||
def _upload_file_url(self, file_url, access_token):
|
||||
"""
|
||||
Upload file to Feishu
|
||||
Supports both local files (file://) and HTTP URLs
|
||||
"""
|
||||
logger.debug(f"[FeiShu] start process file, file_url={file_url}")
|
||||
|
||||
# Check if it's a local file path (file:// protocol)
|
||||
if file_url.startswith("file://"):
|
||||
local_path = file_url[7:] # Remove "file://" prefix
|
||||
logger.info(f"[FeiShu] uploading local file: {local_path}")
|
||||
|
||||
if not os.path.exists(local_path):
|
||||
logger.error(f"[FeiShu] local file not found: {local_path}")
|
||||
return None
|
||||
|
||||
# Get file info
|
||||
file_name = os.path.basename(local_path)
|
||||
file_ext = os.path.splitext(file_name)[1].lower()
|
||||
|
||||
# Determine file type for Feishu API
|
||||
# Feishu supports: opus, mp4, pdf, doc, xls, ppt, stream (other types)
|
||||
file_type_map = {
|
||||
'.opus': 'opus',
|
||||
'.mp4': 'mp4',
|
||||
'.pdf': 'pdf',
|
||||
'.doc': 'doc', '.docx': 'doc',
|
||||
'.xls': 'xls', '.xlsx': 'xls',
|
||||
'.ppt': 'ppt', '.pptx': 'ppt',
|
||||
}
|
||||
file_type = file_type_map.get(file_ext, 'stream') # Default to stream for other types
|
||||
|
||||
# Upload file to Feishu
|
||||
upload_url = "https://open.feishu.cn/open-apis/im/v1/files"
|
||||
data = {'file_type': file_type, 'file_name': file_name}
|
||||
headers = {'Authorization': f'Bearer {access_token}'}
|
||||
|
||||
try:
|
||||
with open(local_path, "rb") as file:
|
||||
upload_response = requests.post(
|
||||
upload_url,
|
||||
files={"file": file},
|
||||
data=data,
|
||||
headers=headers,
|
||||
timeout=(5, 30) # 5s connect, 30s read timeout
|
||||
)
|
||||
logger.info(f"[FeiShu] upload file response, status={upload_response.status_code}, res={upload_response.content}")
|
||||
|
||||
response_data = upload_response.json()
|
||||
if response_data.get("code") == 0:
|
||||
return response_data.get("data").get("file_key")
|
||||
else:
|
||||
logger.error(f"[FeiShu] upload file failed: {response_data}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"[FeiShu] upload file exception: {e}")
|
||||
return None
|
||||
|
||||
# For HTTP URLs, download first then upload
|
||||
try:
|
||||
response = requests.get(file_url, timeout=(5, 30))
|
||||
if response.status_code != 200:
|
||||
logger.error(f"[FeiShu] download file failed, status={response.status_code}")
|
||||
return None
|
||||
|
||||
# Save to temp file
|
||||
import uuid
|
||||
file_name = os.path.basename(file_url)
|
||||
temp_name = str(uuid.uuid4()) + "_" + file_name
|
||||
|
||||
with open(temp_name, "wb") as file:
|
||||
file.write(response.content)
|
||||
|
||||
# Upload
|
||||
file_ext = os.path.splitext(file_name)[1].lower()
|
||||
file_type_map = {
|
||||
'.opus': 'opus', '.mp4': 'mp4', '.pdf': 'pdf',
|
||||
'.doc': 'doc', '.docx': 'doc',
|
||||
'.xls': 'xls', '.xlsx': 'xls',
|
||||
'.ppt': 'ppt', '.pptx': 'ppt',
|
||||
}
|
||||
file_type = file_type_map.get(file_ext, 'stream')
|
||||
|
||||
upload_url = "https://open.feishu.cn/open-apis/im/v1/files"
|
||||
data = {'file_type': file_type, 'file_name': file_name}
|
||||
headers = {'Authorization': f'Bearer {access_token}'}
|
||||
|
||||
with open(temp_name, "rb") as file:
|
||||
upload_response = requests.post(upload_url, files={"file": file}, data=data, headers=headers)
|
||||
logger.info(f"[FeiShu] upload file, res={upload_response.content}")
|
||||
|
||||
response_data = upload_response.json()
|
||||
os.remove(temp_name) # Clean up temp file
|
||||
|
||||
if response_data.get("code") == 0:
|
||||
return response_data.get("data").get("file_key")
|
||||
else:
|
||||
logger.error(f"[FeiShu] upload file failed: {response_data}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"[FeiShu] upload file from URL exception: {e}")
|
||||
return None
|
||||
|
||||
def _compose_context(self, ctype: ContextType, content, **kwargs):
|
||||
context = Context(ctype, content)
|
||||
context.kwargs = kwargs
|
||||
@@ -291,13 +545,18 @@ class FeiShuChanel(ChatChannel):
|
||||
|
||||
cmsg = context["msg"]
|
||||
|
||||
# Set session_id based on chat type to ensure proper session isolation
|
||||
# Set session_id based on chat type
|
||||
if cmsg.is_group:
|
||||
# Group chat: combine user_id and group_id to create unique session per user per group
|
||||
# This ensures:
|
||||
# - Same user in different groups have separate conversation histories
|
||||
# - Same user in private chat and group chat have separate histories
|
||||
context["session_id"] = f"{cmsg.from_user_id}:{cmsg.other_user_id}"
|
||||
# Group chat: check if group_shared_session is enabled
|
||||
if conf().get("group_shared_session", True):
|
||||
# All users in the group share the same session context
|
||||
context["session_id"] = cmsg.other_user_id # group_id
|
||||
else:
|
||||
# Each user has their own session within the group
|
||||
# This ensures:
|
||||
# - Same user in different groups have separate conversation histories
|
||||
# - Same user in private chat and group chat have separate histories
|
||||
context["session_id"] = f"{cmsg.from_user_id}:{cmsg.other_user_id}"
|
||||
else:
|
||||
# Private chat: use user_id only
|
||||
context["session_id"] = cmsg.from_user_id
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
from bridge.context import ContextType
|
||||
from channel.chat_message import ChatMessage
|
||||
import json
|
||||
import os
|
||||
import requests
|
||||
from common.log import logger
|
||||
from common.tmp_dir import TmpDir
|
||||
from common import utils
|
||||
from config import conf
|
||||
|
||||
|
||||
class FeishuMessage(ChatMessage):
|
||||
@@ -22,6 +24,99 @@ class FeishuMessage(ChatMessage):
|
||||
self.ctype = ContextType.TEXT
|
||||
content = json.loads(msg.get('content'))
|
||||
self.content = content.get("text").strip()
|
||||
elif msg_type == "image":
|
||||
# 单张图片消息,不处理和存储
|
||||
self.ctype = ContextType.IMAGE
|
||||
content = json.loads(msg.get("content"))
|
||||
image_key = content.get("image_key")
|
||||
# 仅记录图片key,不下载
|
||||
self.content = f"[图片: {image_key}]"
|
||||
logger.info(f"[FeiShu] Received single image message, key={image_key}, skipped download")
|
||||
elif msg_type == "post":
|
||||
# 富文本消息,可能包含图片、文本等多种元素
|
||||
content = json.loads(msg.get("content"))
|
||||
|
||||
# 飞书富文本消息结构:content 直接包含 title 和 content 数组
|
||||
# 不是嵌套在 post 字段下
|
||||
title = content.get("title", "")
|
||||
content_list = content.get("content", [])
|
||||
|
||||
logger.info(f"[FeiShu] Post message - title: '{title}', content_list length: {len(content_list)}")
|
||||
|
||||
# 收集所有图片和文本
|
||||
image_keys = []
|
||||
text_parts = []
|
||||
|
||||
if title:
|
||||
text_parts.append(title)
|
||||
|
||||
for block in content_list:
|
||||
logger.debug(f"[FeiShu] Processing block: {block}")
|
||||
# block 本身就是元素列表
|
||||
if not isinstance(block, list):
|
||||
continue
|
||||
|
||||
for element in block:
|
||||
element_tag = element.get("tag")
|
||||
logger.debug(f"[FeiShu] Element tag: {element_tag}, element: {element}")
|
||||
if element_tag == "img":
|
||||
# 找到图片元素
|
||||
image_key = element.get("image_key")
|
||||
if image_key:
|
||||
image_keys.append(image_key)
|
||||
elif element_tag == "text":
|
||||
# 文本元素
|
||||
text_content = element.get("text", "")
|
||||
if text_content:
|
||||
text_parts.append(text_content)
|
||||
|
||||
logger.info(f"[FeiShu] Parsed - images: {len(image_keys)}, text_parts: {text_parts}")
|
||||
|
||||
# 富文本消息统一作为文本消息处理
|
||||
self.ctype = ContextType.TEXT
|
||||
|
||||
if image_keys:
|
||||
# 如果包含图片,下载并在文本中引用本地路径
|
||||
workspace_root = os.path.expanduser(conf().get("agent_workspace", "~/cow"))
|
||||
tmp_dir = os.path.join(workspace_root, "tmp")
|
||||
os.makedirs(tmp_dir, exist_ok=True)
|
||||
|
||||
# 保存图片路径映射
|
||||
self.image_paths = {}
|
||||
for image_key in image_keys:
|
||||
image_path = os.path.join(tmp_dir, f"{image_key}.png")
|
||||
self.image_paths[image_key] = image_path
|
||||
|
||||
def _download_images():
|
||||
for image_key, image_path in self.image_paths.items():
|
||||
url = f"https://open.feishu.cn/open-apis/im/v1/messages/{self.msg_id}/resources/{image_key}"
|
||||
headers = {"Authorization": "Bearer " + access_token}
|
||||
params = {"type": "image"}
|
||||
response = requests.get(url=url, headers=headers, params=params)
|
||||
if response.status_code == 200:
|
||||
with open(image_path, "wb") as f:
|
||||
f.write(response.content)
|
||||
logger.info(f"[FeiShu] Image downloaded from post message, key={image_key}, path={image_path}")
|
||||
else:
|
||||
logger.error(f"[FeiShu] Failed to download image from post, key={image_key}, status={response.status_code}")
|
||||
|
||||
# 立即下载图片,不使用延迟下载
|
||||
# 因为 TEXT 类型消息不会调用 prepare()
|
||||
_download_images()
|
||||
|
||||
# 构建消息内容:文本 + 图片路径
|
||||
content_parts = []
|
||||
if text_parts:
|
||||
content_parts.append("\n".join(text_parts).strip())
|
||||
for image_key, image_path in self.image_paths.items():
|
||||
content_parts.append(f"[图片: {image_path}]")
|
||||
|
||||
self.content = "\n".join(content_parts)
|
||||
logger.info(f"[FeiShu] Received post message with {len(image_keys)} image(s) and text: {self.content}")
|
||||
else:
|
||||
# 纯文本富文本消息
|
||||
self.content = "\n".join(text_parts).strip() if text_parts else "[富文本消息]"
|
||||
logger.info(f"[FeiShu] Received post message (text only): {self.content}")
|
||||
elif msg_type == "file":
|
||||
self.ctype = ContextType.FILE
|
||||
content = json.loads(msg.get("content"))
|
||||
|
||||
Reference in New Issue
Block a user