feat: 新增飞书和文件工具MCP服务并优化机器人配置
- 新增飞书MCP服务,包含图片上传和消息卡片发送功能 - 新增文件工具MCP服务,提供文件转JSON和data URI功能 - 更新机器人MCP服务配置,移除环境变量硬编码 - 升级机器人版本至0.1.28并优化迎宾功能参数 - 添加.gitignore和项目配置文件
This commit is contained in:
1
lzwcai_lark_mcp/lzwcai_lark_mcp/__init__.py
Normal file
1
lzwcai_lark_mcp/lzwcai_lark_mcp/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
__all__ = ["main"]
|
||||
9
lzwcai_lark_mcp/lzwcai_lark_mcp/config.py
Normal file
9
lzwcai_lark_mcp/lzwcai_lark_mcp/config.py
Normal file
@@ -0,0 +1,9 @@
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
|
||||
class Config:
|
||||
APP_ID = os.getenv("app_id", "")
|
||||
APP_SECRET = os.getenv("app_secret", "")
|
||||
85
lzwcai_lark_mcp/lzwcai_lark_mcp/main.py
Normal file
85
lzwcai_lark_mcp/lzwcai_lark_mcp/main.py
Normal file
@@ -0,0 +1,85 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from typing import Tuple
|
||||
|
||||
import requests
|
||||
from mcp.server import Server
|
||||
from mcp.server.stdio import stdio_server
|
||||
|
||||
from .config import Config
|
||||
from .tools import handle_call_tool, tools
|
||||
|
||||
|
||||
logging.basicConfig(
|
||||
level=getattr(logging, os.getenv("LOG_LEVEL", "INFO"), logging.INFO),
|
||||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LarkMcpServer:
|
||||
def __init__(self) -> None:
|
||||
self.server = Server("lzwcai-mcpskills-lark-mcp")
|
||||
self.tenant_access_token: str | None = None
|
||||
self.token_expire_at: float = 0
|
||||
self._register_handlers()
|
||||
|
||||
def _register_handlers(self) -> None:
|
||||
@self.server.list_tools()
|
||||
async def list_tools():
|
||||
return tools
|
||||
|
||||
@self.server.call_tool()
|
||||
async def call_tool(name: str, arguments: dict):
|
||||
await self.ensure_token()
|
||||
return await handle_call_tool(name, arguments, self.tenant_access_token or "")
|
||||
|
||||
async def ensure_token(self) -> None:
|
||||
if self.tenant_access_token and time.time() < self.token_expire_at - 60:
|
||||
return
|
||||
token, expires_in = await asyncio.to_thread(self._request_token)
|
||||
self.tenant_access_token = token
|
||||
self.token_expire_at = time.time() + expires_in
|
||||
|
||||
def _request_token(self) -> Tuple[str, int]:
|
||||
if not Config.APP_ID or not Config.APP_SECRET:
|
||||
raise ValueError("app_id or app_secret is missing")
|
||||
auth_url = "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal"
|
||||
headers = {"Content-Type": "application/json"}
|
||||
payload = {"app_id": Config.APP_ID, "app_secret": Config.APP_SECRET}
|
||||
response = requests.post(
|
||||
auth_url,
|
||||
json=payload,
|
||||
headers=headers,
|
||||
timeout=10
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
if data.get("code") not in (0, None):
|
||||
raise RuntimeError(f"lark auth failed: {data}")
|
||||
token = data.get("tenant_access_token")
|
||||
if not token:
|
||||
raise RuntimeError(f"lark auth response missing token: {data}")
|
||||
expires_in = int(data.get("expire", 3600))
|
||||
return token, expires_in
|
||||
|
||||
|
||||
async def _main() -> None:
|
||||
server = LarkMcpServer()
|
||||
await server.ensure_token()
|
||||
async with stdio_server() as (read_stream, write_stream):
|
||||
await server.server.run(
|
||||
read_stream,
|
||||
write_stream,
|
||||
server.server.create_initialization_options()
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
asyncio.run(_main())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
280
lzwcai_lark_mcp/lzwcai_lark_mcp/tools.py
Normal file
280
lzwcai_lark_mcp/lzwcai_lark_mcp/tools.py
Normal file
@@ -0,0 +1,280 @@
|
||||
import json
|
||||
import mimetypes
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import Dict, List
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import requests
|
||||
from mcp.types import Tool, TextContent
|
||||
|
||||
tools = [
|
||||
Tool(
|
||||
name="upload_image_by_url",
|
||||
description="上传图片并返回image_key,入参为可下载的文件URL",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"image_type": {
|
||||
"type": "string",
|
||||
"description": "图片类型,例如 message"
|
||||
},
|
||||
"url": {
|
||||
"type": "string",
|
||||
"description": "可直接下载的图片文件URL"
|
||||
}
|
||||
},
|
||||
"required": ["url"]
|
||||
}
|
||||
),
|
||||
Tool(
|
||||
name="send_card_message",
|
||||
description="发送消息卡片,入参为receiver_ids、person_id和image_key",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"receiver_ids": {
|
||||
"type": "array",
|
||||
"description": "消息接收者ID列表",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"person_id": {
|
||||
"type": "string",
|
||||
"description": "卡片内person组件的ID"
|
||||
},
|
||||
"image_key": {
|
||||
"type": "string",
|
||||
"description": "图片image_key"
|
||||
}
|
||||
},
|
||||
"required": ["image_key", "person_id"]
|
||||
}
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def upload_image_by_url(token: str, url: str, image_type: str) -> str:
|
||||
download_response = requests.get(url, stream=True, timeout=30)
|
||||
download_response.raise_for_status()
|
||||
content = download_response.content
|
||||
content_disposition = download_response.headers.get("Content-Disposition") or download_response.headers.get("content-disposition")
|
||||
filename = ""
|
||||
if content_disposition:
|
||||
match = re.search(r'filename\*?=(?:UTF-8\'\')?"?([^";]+)"?', content_disposition)
|
||||
if match:
|
||||
filename = match.group(1).split("/")[-1]
|
||||
if not filename:
|
||||
path = urlparse(url).path
|
||||
filename = path.split("/")[-1] if path else ""
|
||||
if not filename:
|
||||
filename = "image"
|
||||
content_type = download_response.headers.get("Content-Type")
|
||||
if not content_type:
|
||||
content_type = mimetypes.guess_type(filename)[0] or "application/octet-stream"
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
data = {"image_type": image_type}
|
||||
files = {"image": (filename, content, content_type)}
|
||||
response = requests.post(
|
||||
"https://open.feishu.cn/open-apis/im/v1/images",
|
||||
headers=headers,
|
||||
data=data,
|
||||
files=files,
|
||||
timeout=30
|
||||
)
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
if payload.get("code") not in (0, None):
|
||||
raise RuntimeError(f"lark image upload failed: {payload}")
|
||||
image_key = payload.get("data", {}).get("image_key")
|
||||
if not image_key:
|
||||
raise RuntimeError(f"lark image upload missing image_key: {payload}")
|
||||
return image_key
|
||||
|
||||
|
||||
def send_card_message(token: str, receiver_id: str, person_id: str, image_key: str) -> str:
|
||||
current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
content = {
|
||||
"schema": "2.0",
|
||||
"config": {
|
||||
"update_multi": True,
|
||||
"locales": ["en_us"],
|
||||
"style": {
|
||||
"text_size": {
|
||||
"normal_v2": {
|
||||
"default": "normal",
|
||||
"pc": "normal",
|
||||
"mobile": "heading"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"body": {
|
||||
"direction": "vertical",
|
||||
"padding": "12px 12px 12px 12px",
|
||||
"elements": [
|
||||
{
|
||||
"tag": "markdown",
|
||||
"content": f"<font color=\"grey\">时间</font> {current_time}",
|
||||
"i18n_content": {
|
||||
"en_us": f"<font color=\"grey\">Incident time</font>\n{current_time}"
|
||||
},
|
||||
"text_align": "center",
|
||||
"text_size": "normal_v2",
|
||||
"margin": "0px 0px 0px 0px"
|
||||
},
|
||||
{
|
||||
"tag": "markdown",
|
||||
"content": f"\n<person id='{person_id}' show_name=true show_avatar=true style='normal'></person><font color=\"grey\">进入仓库</font>",
|
||||
"i18n_content": {
|
||||
"en_us": "<font color=\"grey\">Alert details</font>\nMobile client crash rate at 5%"
|
||||
},
|
||||
"text_align": "center",
|
||||
"text_size": "normal",
|
||||
"margin": "0px 0px 0px 0px"
|
||||
},
|
||||
{
|
||||
"tag": "column_set",
|
||||
"horizontal_spacing": "8px",
|
||||
"horizontal_align": "left",
|
||||
"columns": [
|
||||
{
|
||||
"tag": "column",
|
||||
"width": "weighted",
|
||||
"elements": [],
|
||||
"vertical_spacing": "8px",
|
||||
"horizontal_align": "left",
|
||||
"vertical_align": "top",
|
||||
"weight": 1
|
||||
},
|
||||
{
|
||||
"tag": "column",
|
||||
"width": "auto",
|
||||
"elements": [
|
||||
{
|
||||
"tag": "img",
|
||||
"img_key": image_key,
|
||||
"preview": True,
|
||||
"transparent": False,
|
||||
"scale_type": "crop_center",
|
||||
"size": "large",
|
||||
"corner_radius": "16px",
|
||||
"margin": "0px 0px 0px 0px"
|
||||
}
|
||||
],
|
||||
"vertical_spacing": "8px",
|
||||
"horizontal_align": "left",
|
||||
"vertical_align": "top"
|
||||
},
|
||||
{
|
||||
"tag": "column",
|
||||
"width": "weighted",
|
||||
"elements": [],
|
||||
"vertical_spacing": "8px",
|
||||
"horizontal_align": "left",
|
||||
"vertical_align": "top",
|
||||
"weight": 1
|
||||
}
|
||||
],
|
||||
"margin": "0px 0px 0px 0px"
|
||||
},
|
||||
{
|
||||
"tag": "hr",
|
||||
"margin": "0px 0px 0px 0px"
|
||||
}
|
||||
]
|
||||
},
|
||||
"header": {
|
||||
"title": {
|
||||
"tag": "plain_text",
|
||||
"content": "人员进入仓库通知",
|
||||
"i18n_content": {
|
||||
"en_us": "[Action Needed] Alert: Process Error - Please Address Promptly"
|
||||
}
|
||||
},
|
||||
"subtitle": {
|
||||
"tag": "plain_text",
|
||||
"content": ""
|
||||
},
|
||||
"template": "wathet",
|
||||
"icon": {
|
||||
"tag": "standard_icon",
|
||||
"token": "bell_filled"
|
||||
},
|
||||
"padding": "12px 12px 12px 12px"
|
||||
}
|
||||
}
|
||||
payload = {
|
||||
"receive_id": receiver_id,
|
||||
"msg_type": "interactive",
|
||||
"content": json.dumps(content, ensure_ascii=False)
|
||||
}
|
||||
headers = {
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
response = requests.post(
|
||||
"https://open.feishu.cn/open-apis/im/v1/messages",
|
||||
params={"receive_id_type": "user_id"},
|
||||
headers=headers,
|
||||
json=payload,
|
||||
timeout=30
|
||||
)
|
||||
try:
|
||||
data = response.json()
|
||||
except ValueError:
|
||||
data = {"raw": response.text}
|
||||
if not response.ok:
|
||||
raise RuntimeError(f"lark send message http error: {data}")
|
||||
if data.get("code") not in (0, None):
|
||||
raise RuntimeError(f"lark send message failed: {data}")
|
||||
message_id = data.get("data", {}).get("message_id")
|
||||
if not message_id:
|
||||
raise RuntimeError(f"lark send message missing message_id: {data}")
|
||||
return message_id
|
||||
|
||||
|
||||
def send_card_messages(token: str, receiver_ids: List[str], person_id: str, image_key: str) -> str:
|
||||
if not receiver_ids:
|
||||
raise ValueError("missing receiver_ids")
|
||||
message_ids = []
|
||||
for receiver_id in receiver_ids:
|
||||
normalized_receiver_id = str(receiver_id).strip()
|
||||
if not normalized_receiver_id:
|
||||
continue
|
||||
message_ids.append(send_card_message(token, normalized_receiver_id, person_id, image_key))
|
||||
if not message_ids:
|
||||
raise ValueError("missing receiver_ids")
|
||||
return json.dumps(message_ids, ensure_ascii=False)
|
||||
|
||||
|
||||
async def handle_call_tool(name: str, arguments: Dict[str, object], token: str) -> List[TextContent]:
|
||||
try:
|
||||
if name == "upload_image_by_url":
|
||||
image_type = str(arguments.get("image_type") or "message").strip()
|
||||
url = str(arguments.get("url", "")).strip()
|
||||
if not image_type:
|
||||
raise ValueError("missing image_type")
|
||||
if not url:
|
||||
raise ValueError("missing url")
|
||||
|
||||
result = upload_image_by_url(token, url, image_type)
|
||||
elif name == "send_card_message":
|
||||
image_key = str(arguments.get("image_key", "")).strip()
|
||||
receiver_ids = arguments.get("receiver_ids")
|
||||
person_id = str(arguments.get("person_id", "")).strip()
|
||||
if not image_key:
|
||||
raise ValueError("missing image_key")
|
||||
if not person_id:
|
||||
raise ValueError("missing person_id")
|
||||
if receiver_ids is not None:
|
||||
if not isinstance(receiver_ids, list):
|
||||
raise ValueError("receiver_ids must be a list")
|
||||
result = send_card_messages(token, receiver_ids, person_id, image_key)
|
||||
else:
|
||||
raise ValueError(f"unknown tool name: {name}")
|
||||
return [TextContent(type="text", text=result)]
|
||||
except Exception as exc:
|
||||
return [TextContent(type="text", text=f"Failed to call tool {name}: {exc}")]
|
||||
Reference in New Issue
Block a user