Merge branch 'main' into pr-836

This commit is contained in:
Re-bin
2026-02-19 17:35:19 +00:00
2 changed files with 139 additions and 57 deletions

View File

@@ -2,6 +2,7 @@
import asyncio import asyncio
import json import json
import os
import re import re
import threading import threading
from collections import OrderedDict from collections import OrderedDict
@@ -17,6 +18,10 @@ from nanobot.config.schema import FeishuConfig
try: try:
import lark_oapi as lark import lark_oapi as lark
from lark_oapi.api.im.v1 import ( from lark_oapi.api.im.v1 import (
CreateFileRequest,
CreateFileRequestBody,
CreateImageRequest,
CreateImageRequestBody,
CreateMessageRequest, CreateMessageRequest,
CreateMessageRequestBody, CreateMessageRequestBody,
CreateMessageReactionRequest, CreateMessageReactionRequest,
@@ -263,7 +268,6 @@ class FeishuChannel(BaseChannel):
before = protected[last_end:m.start()].strip() before = protected[last_end:m.start()].strip()
if before: if before:
elements.append({"tag": "markdown", "content": before}) elements.append({"tag": "markdown", "content": before})
level = len(m.group(1))
text = m.group(2).strip() text = m.group(2).strip()
elements.append({ elements.append({
"tag": "div", "tag": "div",
@@ -284,47 +288,125 @@ class FeishuChannel(BaseChannel):
return elements or [{"tag": "markdown", "content": content}] return elements or [{"tag": "markdown", "content": content}]
_IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".ico", ".tiff", ".tif"}
_AUDIO_EXTS = {".opus"}
_FILE_TYPE_MAP = {
".opus": "opus", ".mp4": "mp4", ".pdf": "pdf", ".doc": "doc", ".docx": "doc",
".xls": "xls", ".xlsx": "xls", ".ppt": "ppt", ".pptx": "ppt",
}
def _upload_image_sync(self, file_path: str) -> str | None:
"""Upload an image to Feishu and return the image_key."""
try:
with open(file_path, "rb") as f:
request = CreateImageRequest.builder() \
.request_body(
CreateImageRequestBody.builder()
.image_type("message")
.image(f)
.build()
).build()
response = self._client.im.v1.image.create(request)
if response.success():
image_key = response.data.image_key
logger.debug(f"Uploaded image {os.path.basename(file_path)}: {image_key}")
return image_key
else:
logger.error(f"Failed to upload image: code={response.code}, msg={response.msg}")
return None
except Exception as e:
logger.error(f"Error uploading image {file_path}: {e}")
return None
def _upload_file_sync(self, file_path: str) -> str | None:
"""Upload a file to Feishu and return the file_key."""
ext = os.path.splitext(file_path)[1].lower()
file_type = self._FILE_TYPE_MAP.get(ext, "stream")
file_name = os.path.basename(file_path)
try:
with open(file_path, "rb") as f:
request = CreateFileRequest.builder() \
.request_body(
CreateFileRequestBody.builder()
.file_type(file_type)
.file_name(file_name)
.file(f)
.build()
).build()
response = self._client.im.v1.file.create(request)
if response.success():
file_key = response.data.file_key
logger.debug(f"Uploaded file {file_name}: {file_key}")
return file_key
else:
logger.error(f"Failed to upload file: code={response.code}, msg={response.msg}")
return None
except Exception as e:
logger.error(f"Error uploading file {file_path}: {e}")
return None
def _send_message_sync(self, receive_id_type: str, receive_id: str, msg_type: str, content: str) -> bool:
"""Send a single message (text/image/file/interactive) synchronously."""
try:
request = CreateMessageRequest.builder() \
.receive_id_type(receive_id_type) \
.request_body(
CreateMessageRequestBody.builder()
.receive_id(receive_id)
.msg_type(msg_type)
.content(content)
.build()
).build()
response = self._client.im.v1.message.create(request)
if not response.success():
logger.error(
f"Failed to send Feishu {msg_type} message: code={response.code}, "
f"msg={response.msg}, log_id={response.get_log_id()}"
)
return False
logger.debug(f"Feishu {msg_type} message sent to {receive_id}")
return True
except Exception as e:
logger.error(f"Error sending Feishu {msg_type} message: {e}")
return False
async def send(self, msg: OutboundMessage) -> None: async def send(self, msg: OutboundMessage) -> None:
"""Send a message through Feishu.""" """Send a message through Feishu, including media (images/files) if present."""
if not self._client: if not self._client:
logger.warning("Feishu client not initialized") logger.warning("Feishu client not initialized")
return return
try: try:
# Determine receive_id_type based on chat_id format receive_id_type = "chat_id" if msg.chat_id.startswith("oc_") else "open_id"
# open_id starts with "ou_", chat_id starts with "oc_" loop = asyncio.get_running_loop()
if msg.chat_id.startswith("oc_"):
receive_id_type = "chat_id"
else:
receive_id_type = "open_id"
# Build card with markdown + table support for file_path in msg.media:
elements = self._build_card_elements(msg.content) if not os.path.isfile(file_path):
card = { logger.warning(f"Media file not found: {file_path}")
"config": {"wide_screen_mode": True}, continue
"elements": elements, ext = os.path.splitext(file_path)[1].lower()
} if ext in self._IMAGE_EXTS:
content = json.dumps(card, ensure_ascii=False) key = await loop.run_in_executor(None, self._upload_image_sync, file_path)
if key:
request = CreateMessageRequest.builder() \ await loop.run_in_executor(
.receive_id_type(receive_id_type) \ None, self._send_message_sync,
.request_body( receive_id_type, msg.chat_id, "image", json.dumps({"image_key": key}),
CreateMessageRequestBody.builder()
.receive_id(msg.chat_id)
.msg_type("interactive")
.content(content)
.build()
).build()
response = self._client.im.v1.message.create(request)
if not response.success():
logger.error(
f"Failed to send Feishu message: code={response.code}, "
f"msg={response.msg}, log_id={response.get_log_id()}"
) )
else: else:
logger.debug(f"Feishu message sent to {msg.chat_id}") key = await loop.run_in_executor(None, self._upload_file_sync, file_path)
if key:
media_type = "audio" if ext in self._AUDIO_EXTS else "file"
await loop.run_in_executor(
None, self._send_message_sync,
receive_id_type, msg.chat_id, media_type, json.dumps({"file_key": key}),
)
if msg.content and msg.content.strip():
card = {"config": {"wide_screen_mode": True}, "elements": self._build_card_elements(msg.content)}
await loop.run_in_executor(
None, self._send_message_sync,
receive_id_type, msg.chat_id, "interactive", json.dumps(card, ensure_ascii=False),
)
except Exception as e: except Exception as e:
logger.error(f"Error sending Feishu message: {e}") logger.error(f"Error sending Feishu message: {e}")

View File

@@ -17,37 +17,37 @@ classifiers = [
] ]
dependencies = [ dependencies = [
"typer>=0.9.0", "typer>=0.20.0,<1.0.0",
"litellm>=1.0.0", "litellm>=1.81.5,<2.0.0",
"pydantic>=2.0.0", "pydantic>=2.12.0,<3.0.0",
"pydantic-settings>=2.0.0", "pydantic-settings>=2.12.0,<3.0.0",
"websockets>=12.0", "websockets>=16.0,<17.0",
"websocket-client>=1.6.0", "websocket-client>=1.9.0,<2.0.0",
"httpx>=0.25.0", "httpx>=0.28.0,<1.0.0",
"oauth-cli-kit>=0.1.1", "oauth-cli-kit>=0.1.3,<1.0.0",
"loguru>=0.7.0", "loguru>=0.7.3,<1.0.0",
"readability-lxml>=0.8.0", "readability-lxml>=0.8.4,<1.0.0",
"rich>=13.0.0", "rich>=14.0.0,<15.0.0",
"croniter>=2.0.0", "croniter>=6.0.0,<7.0.0",
"dingtalk-stream>=0.4.0", "dingtalk-stream>=0.24.0,<1.0.0",
"python-telegram-bot[socks]>=21.0", "python-telegram-bot[socks]>=22.0,<23.0",
"lark-oapi>=1.0.0", "lark-oapi>=1.5.0,<2.0.0",
"socksio>=1.0.0", "socksio>=1.0.0,<2.0.0",
"python-socketio>=5.11.0", "python-socketio>=5.16.0,<6.0.0",
"msgpack>=1.0.8", "msgpack>=1.1.0,<2.0.0",
"slack-sdk>=3.26.0", "slack-sdk>=3.39.0,<4.0.0",
"slackify-markdown>=0.2.0", "slackify-markdown>=0.2.0,<1.0.0",
"qq-botpy>=1.0.0", "qq-botpy>=1.2.0,<2.0.0",
"python-socks[asyncio]>=2.4.0", "python-socks[asyncio]>=2.8.0,<3.0.0",
"prompt-toolkit>=3.0.0", "prompt-toolkit>=3.0.50,<4.0.0",
"mcp>=1.0.0", "mcp>=1.26.0,<2.0.0",
"json-repair>=0.30.0", "json-repair>=0.57.0,<1.0.0",
] ]
[project.optional-dependencies] [project.optional-dependencies]
dev = [ dev = [
"pytest>=7.0.0", "pytest>=9.0.0,<10.0.0",
"pytest-asyncio>=0.21.0", "pytest-asyncio>=1.3.0,<2.0.0",
"ruff>=0.1.0", "ruff>=0.1.0",
] ]