ForcePilot/backend/package/yuxi/channel/extensions/feishu/outbound.py
Kris 5e91bb9985 feat(feishu): 新增飞书渠道完整插件实现
新增飞书渠道插件,包含基础配置、事件处理、消息收发、工具调用、权限管理等完整功能模块,支持WebSocket长连接、卡片消息、流式响应、群管理、审批通知等能力
2026-05-21 10:46:42 +08:00

841 lines
31 KiB
Python

from __future__ import annotations
import json
import logging
from typing import Any
from yuxi.channel.extensions.feishu.client import get_client
from yuxi.channel.extensions.feishu.config import FeishuConfigAdapter
from yuxi.channel.extensions.feishu.format import text_to_post_message, chunk_post_content, markdown_to_feishu_post
from yuxi.channel.extensions.feishu.media import upload_image as _upload_media_image, upload_file as _upload_media_file
from yuxi.channel.extensions.feishu.types import FeishuAccount
logger = logging.getLogger(__name__)
REPLY_DEGRADE_ERRORS = frozenset({230011, 231003})
class FeishuOutboundAdapter:
delivery_mode = "direct"
def __init__(self):
self._config_adapter = FeishuConfigAdapter()
async def _resolve_account(self, account_id: str | None, config: dict | None = None) -> FeishuAccount:
aid = account_id or self._config_adapter.default_account_id(config or {})
return await self._config_adapter.resolve_account(aid, config)
async def send_text(
self,
target_id: str,
content: str,
*,
reply_to_id: str | None = None,
thread_id: str | None = None,
account_id: str | None = None,
config: dict | None = None,
) -> dict:
account = await self._resolve_account(account_id, config)
if not account.is_configured():
return {"success": False, "error": "Account not configured"}
try:
client = get_client(account.app_id, account.app_secret, account.domain, account.http_timeout_ms)
body = self._build_message_body(target_id, content, reply_to_id=reply_to_id)
import lark_oapi
req = lark_oapi.im.v1.CreateMessageRequest(
receive_id_type="chat_id",
request_body=lark_oapi.im.v1.CreateMessageRequestBody(**body),
)
resp = await client.im.v1.message.create_async(req)
return self._parse_response(resp)
except ImportError:
return {"success": False, "error": "lark-oapi SDK not installed"}
except Exception:
logger.exception("Feishu send_text error")
return {"success": False, "error": "Internal error"}
async def send_card(
self,
target_id: str,
card_json: dict,
*,
reply_to_id: str | None = None,
thread_id: str | None = None,
account_id: str | None = None,
config: dict | None = None,
) -> dict:
account = await self._resolve_account(account_id, config)
if not account.is_configured():
return {"success": False, "error": "Account not configured"}
try:
client = get_client(account.app_id, account.app_secret, account.domain, account.http_timeout_ms)
body: dict[str, Any] = {
"receive_id": target_id,
"msg_type": "interactive",
"content": json.dumps(card_json, ensure_ascii=False),
}
if reply_to_id:
body["root_id"] = reply_to_id
import lark_oapi
req = lark_oapi.im.v1.CreateMessageRequest(
receive_id_type="chat_id",
request_body=lark_oapi.im.v1.CreateMessageRequestBody(**body),
)
resp = await client.im.v1.message.create_async(req)
return self._parse_response(resp)
except ImportError:
return {"success": False, "error": "lark-oapi SDK not installed"}
except Exception:
logger.exception("Feishu send_card error")
return {"success": False, "error": "Internal error"}
async def send_markdown_card(
self,
target_id: str,
text: str,
*,
account_id: str | None = None,
config: dict | None = None,
) -> dict:
from yuxi.channel.extensions.feishu.card import build_markdown_card
return await self.send_card(target_id, build_markdown_card(text), account_id=account_id, config=config)
async def send_structured_card(
self,
target_id: str,
text: str,
*,
title: str | None = None,
template: str = "blue",
account_id: str | None = None,
config: dict | None = None,
) -> dict:
from yuxi.channel.extensions.feishu.card import build_structured_card
return await self.send_card(
target_id, build_structured_card(text, title=title, template=template),
account_id=account_id, config=config,
)
async def reply_message(
self,
target_id: str,
content: str,
message_id: str,
*,
account_id: str | None = None,
config: dict | None = None,
) -> dict:
account = await self._resolve_account(account_id, config)
if not account.is_configured():
return {"success": False, "error": "Account not configured"}
try:
client = get_client(account.app_id, account.app_secret, account.domain, account.http_timeout_ms)
post_content = text_to_post_message(content)
body: dict[str, Any] = {
"content": post_content,
"msg_type": "post",
}
import lark_oapi
req = lark_oapi.im.v1.ReplyMessageRequest(
message_id=message_id,
request_body=lark_oapi.im.v1.ReplyMessageRequestBody(**body),
)
resp = await client.im.v1.message.reply_async(req)
if resp.success():
return {"success": True, "msg_id": getattr(resp.data, "message_id", "")}
err_code = getattr(resp, "code", 0)
err_msg = getattr(resp, "msg", "")
if err_code in REPLY_DEGRADE_ERRORS:
logger.debug("Reply target not found, degrading to direct send: code=%d", err_code)
return await self.send_text(
target_id, content, account_id=account_id, config=config,
)
return {"success": False, "error": err_msg, "code": err_code}
except ImportError:
return {"success": False, "error": "lark-oapi SDK not installed"}
except Exception:
logger.exception("Feishu reply_message error")
return {"success": False, "error": "Internal error"}
async def thread_reply(
self,
target_id: str,
content: str,
root_id: str,
*,
account_id: str | None = None,
config: dict | None = None,
) -> dict:
return await self.send_text(
target_id, content, reply_to_id=root_id,
account_id=account_id, config=config,
)
async def delete_message(
self,
message_id: str,
*,
account_id: str | None = None,
config: dict | None = None,
) -> dict:
account = await self._resolve_account(account_id, config)
if not account.is_configured():
return {"success": False, "error": "Account not configured"}
try:
client = get_client(account.app_id, account.app_secret, account.domain, account.http_timeout_ms)
import lark_oapi
req = lark_oapi.im.v1.DeleteMessageRequest(message_id=message_id)
resp = await client.im.v1.message.delete_async(req)
return self._parse_response(resp)
except ImportError:
return {"success": False, "error": "lark-oapi SDK not installed"}
except Exception:
logger.exception("Feishu delete_message error")
return {"success": False, "error": "Internal error"}
async def forward_message(
self,
message_id: str,
target_chat_id: str,
*,
account_id: str | None = None,
config: dict | None = None,
) -> dict:
account = await self._resolve_account(account_id, config)
if not account.is_configured():
return {"success": False, "error": "Account not configured"}
try:
client = get_client(account.app_id, account.app_secret, account.domain, account.http_timeout_ms)
import lark_oapi
req = lark_oapi.im.v1.ForwardMessageRequest(
message_id=message_id,
request_body=lark_oapi.im.v1.ForwardMessageRequestBody(
receive_id=target_chat_id,
),
)
resp = await client.im.v1.message.forward_async(req)
return self._parse_response(resp)
except ImportError:
return {"success": False, "error": "lark-oapi SDK not installed"}
except Exception:
logger.exception("Feishu forward_message error")
return {"success": False, "error": "Internal error"}
async def merge_forward(
self,
target_id: str,
message_ids: list[str],
*,
account_id: str | None = None,
config: dict | None = None,
) -> dict:
account = await self._resolve_account(account_id, config)
if not account.is_configured():
return {"success": False, "error": "Account not configured"}
try:
client = get_client(account.app_id, account.app_secret, account.domain, account.http_timeout_ms)
import lark_oapi
req = lark_oapi.im.v1.MergeForwardMessageRequest(
receive_id_type="chat_id",
request_body=lark_oapi.im.v1.MergeForwardMessageRequestBody(
receive_id=target_id,
message_id_list=message_ids,
),
)
resp = await client.im.v1.message.merge_forward_async(req)
return self._parse_response(resp)
except ImportError:
return {"success": False, "error": "lark-oapi SDK not installed"}
except Exception:
logger.exception("Feishu merge_forward error")
return {"success": False, "error": "Internal error"}
async def edit_message(
self,
message_id: str,
content: str,
*,
account_id: str | None = None,
config: dict | None = None,
) -> dict:
account = await self._resolve_account(account_id, config)
if not account.is_configured():
return {"success": False, "error": "Account not configured"}
try:
client = get_client(account.app_id, account.app_secret, account.domain, account.http_timeout_ms)
post_content = text_to_post_message(content)
import lark_oapi
req = lark_oapi.im.v1.PatchMessageRequest(
message_id=message_id,
request_body=lark_oapi.im.v1.PatchMessageRequestBody(
content=post_content,
),
)
resp = await client.im.v1.message.patch_async(req)
return self._parse_response(resp)
except ImportError:
return {"success": False, "error": "lark-oapi SDK not installed"}
except Exception:
logger.exception("Feishu edit_message error")
return {"success": False, "error": "Internal error"}
async def send_image(
self,
target_id: str,
image_key: str,
*,
account_id: str | None = None,
config: dict | None = None,
) -> dict:
account = await self._resolve_account(account_id, config)
if not account.is_configured():
return {"success": False, "error": "Account not configured"}
try:
client = get_client(account.app_id, account.app_secret, account.domain, account.http_timeout_ms)
import lark_oapi
req = lark_oapi.im.v1.CreateMessageRequest(
receive_id_type="chat_id",
request_body=lark_oapi.im.v1.CreateMessageRequestBody(
receive_id=target_id,
msg_type="image",
content=json.dumps({"image_key": image_key}),
),
)
resp = await client.im.v1.message.create_async(req)
return self._parse_response(resp)
except ImportError:
return {"success": False, "error": "lark-oapi SDK not installed"}
except Exception:
logger.exception("Feishu send_image error")
return {"success": False, "error": "Internal error"}
async def send_image_by_path(
self,
target_id: str,
file_path: str,
*,
account_id: str | None = None,
config: dict | None = None,
) -> dict:
account = await self._resolve_account(account_id, config)
if not account.is_configured():
return {"success": False, "error": "Account not configured"}
upload_result = await _upload_media_image(
account.app_id, account.app_secret, account.domain, file_path, account.http_timeout_ms
)
if not upload_result.get("success"):
return upload_result
image_key = upload_result.get("image_key", "")
return await self.send_image(target_id, image_key, account_id=account_id, config=config)
async def send_file(
self,
target_id: str,
file_key: str,
file_type: str = "stream",
*,
account_id: str | None = None,
config: dict | None = None,
) -> dict:
account = await self._resolve_account(account_id, config)
if not account.is_configured():
return {"success": False, "error": "Account not configured"}
try:
client = get_client(account.app_id, account.app_secret, account.domain, account.http_timeout_ms)
import lark_oapi
req = lark_oapi.im.v1.CreateMessageRequest(
receive_id_type="chat_id",
request_body=lark_oapi.im.v1.CreateMessageRequestBody(
receive_id=target_id,
msg_type="file",
content=json.dumps({"file_key": file_key}),
),
)
resp = await client.im.v1.message.create_async(req)
return self._parse_response(resp)
except ImportError:
return {"success": False, "error": "lark-oapi SDK not installed"}
except Exception:
logger.exception("Feishu send_file error")
return {"success": False, "error": "Internal error"}
async def send_file_by_path(
self,
target_id: str,
file_path: str,
*,
file_type: str = "stream",
filename: str = "",
account_id: str | None = None,
config: dict | None = None,
) -> dict:
account = await self._resolve_account(account_id, config)
if not account.is_configured():
return {"success": False, "error": "Account not configured"}
upload_result = await _upload_media_file(
account.app_id, account.app_secret, account.domain,
file_path, file_type, filename, account.http_timeout_ms,
)
if not upload_result.get("success"):
return upload_result
file_key = upload_result.get("file_key", "")
return await self.send_file(target_id, file_key, file_type, account_id=account_id, config=config)
async def send_audio_by_path(
self,
target_id: str,
file_path: str,
*,
account_id: str | None = None,
config: dict | None = None,
) -> dict:
account = await self._resolve_account(account_id, config)
if not account.is_configured():
return {"success": False, "error": "Account not configured"}
from yuxi.channel.extensions.feishu.media import detect_file_type
file_type = detect_file_type(file_path)
upload_result = await _upload_media_file(
account.app_id, account.app_secret, account.domain,
file_path, file_type, "", account.http_timeout_ms,
)
if not upload_result.get("success"):
return upload_result
file_key = upload_result.get("file_key", "")
return await self._send_media_by_key(target_id, file_key, "audio", account_id=account_id)
async def send_video_by_path(
self,
target_id: str,
file_path: str,
*,
account_id: str | None = None,
config: dict | None = None,
) -> dict:
account = await self._resolve_account(account_id, config)
if not account.is_configured():
return {"success": False, "error": "Account not configured"}
from yuxi.channel.extensions.feishu.media import detect_file_type
file_type = detect_file_type(file_path)
upload_result = await _upload_media_file(
account.app_id, account.app_secret, account.domain,
file_path, file_type, "", account.http_timeout_ms,
)
if not upload_result.get("success"):
return upload_result
file_key = upload_result.get("file_key", "")
return await self._send_media_by_key(target_id, file_key, "media", account_id=account_id)
async def _send_media_by_key(
self,
target_id: str,
file_key: str,
msg_type: str,
*,
account_id: str | None = None,
) -> dict:
account = await self._resolve_account(account_id, None)
if not account.is_configured():
return {"success": False, "error": "Account not configured"}
try:
client = get_client(account.app_id, account.app_secret, account.domain, account.http_timeout_ms)
if msg_type == "audio":
content_json = json.dumps({"file_key": file_key})
elif msg_type == "media":
content_json = json.dumps({"file_key": file_key, "image_key": ""})
else:
return {"success": False, "error": f"Unsupported media type: {msg_type}"}
import lark_oapi
req = lark_oapi.im.v1.CreateMessageRequest(
receive_id_type="chat_id",
request_body=lark_oapi.im.v1.CreateMessageRequestBody(
receive_id=target_id,
msg_type=msg_type,
content=content_json,
),
)
resp = await client.im.v1.message.create_async(req)
return self._parse_response(resp)
except ImportError:
return {"success": False, "error": "lark-oapi SDK not installed"}
except Exception:
logger.exception("Feishu _send_media_by_key error")
return {"success": False, "error": "Internal error"}
async def send_media(
self,
target_id: str,
media_url: str,
media_type: str,
reply_to_id: str | None = None,
thread_id: str | None = None,
) -> None:
account_id = None
try:
await self._resolve_account(account_id, None)
except Exception:
logger.warning("Feishu send_media - could not resolve account")
return
if media_type.startswith("image"):
await self.send_image_by_path(target_id, media_url, account_id=account_id)
elif media_type.startswith("audio") or media_type.startswith("voice"):
await self.send_audio_by_path(target_id, media_url, account_id=account_id)
elif media_type.startswith("video"):
await self.send_video_by_path(target_id, media_url, account_id=account_id)
def chunker(self, text: str, limit: int, ctx: object | None = None) -> list[str]:
if limit >= 28000:
post = markdown_to_feishu_post(text)
elements = post.get("zh_cn", {}).get("content", [[]])
chunks = chunk_post_content(elements, limit=min(limit, 28000))
result = []
for chunk_elements in chunks:
chunk_post = {
"zh_cn": {
"title": "",
"content": chunk_elements,
}
}
result.append(json.dumps(chunk_post, ensure_ascii=False))
return result
if len(text) <= limit:
return [text]
chunks = []
remaining = text
while len(remaining) > limit:
split_at = remaining.rfind("\n", 0, limit)
if split_at == -1 or split_at < limit // 2:
split_at = limit
chunks.append(remaining[:split_at])
remaining = remaining[split_at:].lstrip("\n")
if remaining:
chunks.append(remaining)
return chunks
async def edit_card(
self,
target_id: str,
message_id: str,
card_content: dict,
*,
account_id: str | None = None,
config: dict | None = None,
) -> str | None:
account = await self._resolve_account(account_id, config)
if not account.is_configured():
return None
try:
client = get_client(account.app_id, account.app_secret, account.domain, account.http_timeout_ms)
import lark_oapi
req = lark_oapi.im.v1.PatchMessageRequest(
message_id=message_id,
request_body=lark_oapi.im.v1.PatchMessageRequestBody(
content=json.dumps(card_content, ensure_ascii=False),
),
)
resp = await client.im.v1.message.patch_async(req)
if resp.success():
return message_id
logger.warning("Feishu edit_card failed: code=%s, msg=%s", resp.code, resp.msg)
return None
except ImportError:
return None
except Exception:
logger.exception("Feishu edit_card error")
return None
async def get_message(
self,
message_id: str,
*,
account_id: str | None = None,
config: dict | None = None,
) -> dict | None:
account = await self._resolve_account(account_id, config)
if not account.is_configured():
return None
try:
client = get_client(account.app_id, account.app_secret, account.domain, account.http_timeout_ms)
from yuxi.channel.extensions.feishu.client import get_message as _get_msg
return await _get_msg(client, message_id)
except Exception:
logger.exception("Feishu get_message error")
return None
async def list_chat_messages(
self,
chat_id: str,
*,
page_token: str = "",
page_size: int = 20,
account_id: str | None = None,
config: dict | None = None,
) -> dict:
account = await self._resolve_account(account_id, config)
if not account.is_configured():
return {"messages": [], "has_more": False, "page_token": ""}
try:
client = get_client(account.app_id, account.app_secret, account.domain, account.http_timeout_ms)
from yuxi.channel.extensions.feishu.client import list_messages as _list_msg
return await _list_msg(client, container_id=chat_id, page_token=page_token, page_size=page_size)
except Exception:
logger.exception("Feishu list_chat_messages error")
return {"messages": [], "has_more": False, "page_token": ""}
async def create_group_chat(
self,
name: str,
user_ids: list[str] | None = None,
description: str = "",
*,
account_id: str | None = None,
config: dict | None = None,
) -> dict:
account = await self._resolve_account(account_id, config)
if not account.is_configured():
return {"success": False, "error": "Account not configured"}
try:
client = get_client(account.app_id, account.app_secret, account.domain, account.http_timeout_ms)
from yuxi.channel.extensions.feishu.client import create_chat
return await create_chat(client, name, description, user_ids or [], "group")
except Exception:
logger.exception("Feishu create_group_chat error")
return {"success": False, "error": "Internal error"}
async def update_group_chat(
self,
chat_id: str,
name: str = "",
description: str = "",
*,
account_id: str | None = None,
config: dict | None = None,
) -> dict:
account = await self._resolve_account(account_id, config)
if not account.is_configured():
return {"success": False, "error": "Account not configured"}
try:
client = get_client(account.app_id, account.app_secret, account.domain, account.http_timeout_ms)
from yuxi.channel.extensions.feishu.client import update_chat
return await update_chat(client, chat_id, name, description)
except Exception:
logger.exception("Feishu update_group_chat error")
return {"success": False, "error": "Internal error"}
async def delete_group_chat(
self,
chat_id: str,
*,
account_id: str | None = None,
config: dict | None = None,
) -> dict:
account = await self._resolve_account(account_id, config)
if not account.is_configured():
return {"success": False, "error": "Account not configured"}
try:
client = get_client(account.app_id, account.app_secret, account.domain, account.http_timeout_ms)
from yuxi.channel.extensions.feishu.client import delete_chat
return await delete_chat(client, chat_id)
except Exception:
logger.exception("Feishu delete_group_chat error")
return {"success": False, "error": "Internal error"}
async def add_group_members(
self,
chat_id: str,
member_ids: list[str],
*,
account_id: str | None = None,
config: dict | None = None,
) -> dict:
account = await self._resolve_account(account_id, config)
if not account.is_configured():
return {"success": False, "error": "Account not configured"}
try:
client = get_client(account.app_id, account.app_secret, account.domain, account.http_timeout_ms)
from yuxi.channel.extensions.feishu.client import add_chat_members
return await add_chat_members(client, chat_id, member_ids)
except Exception:
logger.exception("Feishu add_group_members error")
return {"success": False, "error": "Internal error"}
async def remove_group_members(
self,
chat_id: str,
member_ids: list[str],
*,
account_id: str | None = None,
config: dict | None = None,
) -> dict:
account = await self._resolve_account(account_id, config)
if not account.is_configured():
return {"success": False, "error": "Account not configured"}
try:
client = get_client(account.app_id, account.app_secret, account.domain, account.http_timeout_ms)
from yuxi.channel.extensions.feishu.client import remove_chat_members
return await remove_chat_members(client, chat_id, member_ids)
except Exception:
logger.exception("Feishu remove_group_members error")
return {"success": False, "error": "Internal error"}
async def list_user_chats(
self,
user_id: str = "",
page_token: str = "",
page_size: int = 50,
*,
account_id: str | None = None,
config: dict | None = None,
) -> dict:
account = await self._resolve_account(account_id, config)
if not account.is_configured():
return {"chats": [], "has_more": False, "page_token": ""}
try:
client = get_client(account.app_id, account.app_secret, account.domain, account.http_timeout_ms)
from yuxi.channel.extensions.feishu.client import list_user_chats as _list_user_chats
return await _list_user_chats(client, user_id, page_token, page_size)
except Exception:
logger.exception("Feishu list_user_chats error")
return {"chats": [], "has_more": False, "page_token": ""}
async def search_chats(
self,
query: str = "",
page_token: str = "",
page_size: int = 50,
*,
account_id: str | None = None,
config: dict | None = None,
) -> dict:
account = await self._resolve_account(account_id, config)
if not account.is_configured():
return {"chats": [], "has_more": False, "page_token": ""}
try:
client = get_client(account.app_id, account.app_secret, account.domain, account.http_timeout_ms)
from yuxi.channel.extensions.feishu.client import search_chats as _search_chats
return await _search_chats(client, query, page_token, page_size)
except Exception:
logger.exception("Feishu search_chats error")
return {"chats": [], "has_more": False, "page_token": ""}
async def list_departments(
self,
parent_department_id: str = "0",
page_token: str = "",
page_size: int = 50,
*,
account_id: str | None = None,
config: dict | None = None,
) -> dict:
account = await self._resolve_account(account_id, config)
if not account.is_configured():
return {"departments": [], "has_more": False, "page_token": ""}
try:
client = get_client(account.app_id, account.app_secret, account.domain, account.http_timeout_ms)
from yuxi.channel.extensions.feishu.client import list_departments as _list_dept
return await _list_dept(client, parent_department_id, page_token, page_size)
except Exception:
logger.exception("Feishu list_departments error")
return {"departments": [], "has_more": False, "page_token": ""}
async def find_users_by_department(
self,
department_id: str,
page_token: str = "",
page_size: int = 50,
*,
account_id: str | None = None,
config: dict | None = None,
) -> dict:
account = await self._resolve_account(account_id, config)
if not account.is_configured():
return {"users": [], "has_more": False, "page_token": ""}
try:
client = get_client(account.app_id, account.app_secret, account.domain, account.http_timeout_ms)
from yuxi.channel.extensions.feishu.client import find_users_by_department as _find_dept_users
return await _find_dept_users(client, department_id, page_token, page_size)
except Exception:
logger.exception("Feishu find_users_by_department error")
return {"users": [], "has_more": False, "page_token": ""}
def _build_message_body(
self,
target_id: str,
content: str,
*,
reply_to_id: str | None = None,
) -> dict[str, Any]:
post_content = text_to_post_message(content)
body: dict[str, Any] = {
"receive_id": target_id,
"msg_type": "post",
"content": post_content,
}
if reply_to_id:
body["root_id"] = reply_to_id
return body
def _parse_response(self, resp) -> dict:
if resp.success():
msg_id = getattr(resp.data, "message_id", "")
return {"success": True, "msg_id": msg_id}
err_code = getattr(resp, "code", 0)
err_msg = getattr(resp, "msg", "")
if err_code in REPLY_DEGRADE_ERRORS:
logger.debug("Reply target not found, code=%d", err_code)
return {"success": False, "error": err_msg, "code": err_code}