312 lines
11 KiB
Python
312 lines
11 KiB
Python
import asyncio
|
|
import json
|
|
import os
|
|
import sys
|
|
import time
|
|
import base64
|
|
from typing import Optional, List
|
|
|
|
NAPCT_WS_URL = 'ws://127.0.0.1:3001'
|
|
RECEIVE_TIMEOUT = 30
|
|
|
|
_ws = None
|
|
_msg_queue: List[dict] = []
|
|
|
|
|
|
def is_image_file(path: str) -> bool:
|
|
ext = os.path.splitext(path)[1].lower()
|
|
return ext in ('.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp')
|
|
|
|
|
|
def is_video_file(path: str) -> bool:
|
|
ext = os.path.splitext(path)[1].lower()
|
|
return ext in ('.mp4', '.mov', '.avi', '.mkv', '.flv', '.wmv', '.webm')
|
|
|
|
|
|
def build_message_segments(text: str, file_paths: Optional[List[str]] = None) -> list:
|
|
"""构建 NapCat 消息段数组"""
|
|
segments = []
|
|
if text:
|
|
segments.append({"type": "text", "data": {"text": text}})
|
|
if file_paths:
|
|
for path in file_paths:
|
|
abs_path = os.path.abspath(path)
|
|
if not os.path.exists(abs_path):
|
|
raise Exception(f"文件不存在: {abs_path}")
|
|
if is_image_file(abs_path):
|
|
segments.append({"type": "image", "data": {"file": f"file://{abs_path}"}})
|
|
elif is_video_file(abs_path):
|
|
segments.append({"type": "video", "data": {"file": f"file://{abs_path}"}})
|
|
else:
|
|
segments.append({"type": "file", "data": {"file": f"file://{abs_path}"}})
|
|
if not segments:
|
|
segments.append({"type": "text", "data": {"text": ""}})
|
|
return segments
|
|
|
|
|
|
async def ensure_ws():
|
|
global _ws
|
|
if _ws is None or _ws.closed:
|
|
import websockets
|
|
_ws = await websockets.connect(NAPCT_WS_URL)
|
|
return _ws
|
|
|
|
|
|
async def ws_send_action(action: str, params: dict) -> dict:
|
|
"""发送 action 到 NapCat 并等待响应"""
|
|
ws = await ensure_ws()
|
|
echo = f"mcp_{int(time.time() * 1000)}"
|
|
payload = {"action": action, "params": params, "echo": echo}
|
|
await ws.send(json.dumps(payload))
|
|
# 等待对应的响应
|
|
timeout_ts = time.time() + 15
|
|
while time.time() < timeout_ts:
|
|
try:
|
|
raw = await asyncio.wait_for(ws.recv(), timeout=2)
|
|
resp = json.loads(raw)
|
|
if resp.get("echo") == echo:
|
|
return resp
|
|
# 不是我们的响应,放入消息队列
|
|
_msg_queue.append(resp)
|
|
except asyncio.TimeoutError:
|
|
continue
|
|
raise Exception("发送消息超时")
|
|
|
|
|
|
async def send_private_message(user_id: int, text: str = "", file_paths: Optional[List[str]] = None) -> dict:
|
|
"""发送私聊消息,可附带图片/视频"""
|
|
segments = build_message_segments(text, file_paths)
|
|
return await ws_send_action("send_private_msg", {
|
|
"user_id": user_id,
|
|
"message": segments
|
|
})
|
|
|
|
|
|
async def send_group_message(group_id: int, text: str = "", file_paths: Optional[List[str]] = None) -> dict:
|
|
"""发送群聊消息,可附带图片/视频"""
|
|
segments = build_message_segments(text, file_paths)
|
|
return await ws_send_action("send_group_msg", {
|
|
"group_id": group_id,
|
|
"message": segments
|
|
})
|
|
|
|
|
|
async def receive_messages(count: int = 1, user_id: Optional[int] = None,
|
|
group_id: Optional[int] = None, timeout: int = RECEIVE_TIMEOUT) -> list:
|
|
"""接收 N 条消息,可按 user_id 或 group_id 过滤"""
|
|
ws = await ensure_ws()
|
|
results = []
|
|
deadline = time.time() + timeout
|
|
while len(results) < count and time.time() < deadline:
|
|
remain = deadline - time.time()
|
|
if remain <= 0:
|
|
break
|
|
try:
|
|
raw = await asyncio.wait_for(ws.recv(), timeout=min(remain, 2))
|
|
msg = json.loads(raw)
|
|
if msg.get("post_type") != "message":
|
|
continue
|
|
msg_user_id = msg.get("user_id")
|
|
msg_group_id = msg.get("group_id") if msg.get("message_type") == "group" else None
|
|
if user_id is not None and msg_user_id != user_id:
|
|
continue
|
|
if group_id is not None and msg_group_id != group_id:
|
|
continue
|
|
|
|
# 提取纯文本和媒体信息
|
|
segments = msg.get("message", [])
|
|
text_parts = []
|
|
media_info = []
|
|
for seg in segments:
|
|
if seg.get("type") == "text":
|
|
text_parts.append(seg.get("data", {}).get("text", ""))
|
|
elif seg.get("type") in ("image", "video"):
|
|
media_info.append({
|
|
"type": seg.get("type"),
|
|
"url": seg.get("data", {}).get("url", ""),
|
|
"file": seg.get("data", {}).get("file", "")
|
|
})
|
|
|
|
results.append({
|
|
"message_id": msg.get("message_id"),
|
|
"user_id": msg_user_id,
|
|
"group_id": msg_group_id,
|
|
"message_type": msg.get("message_type"),
|
|
"text": "".join(text_parts).strip(),
|
|
"media": media_info,
|
|
"raw": msg
|
|
})
|
|
_msg_queue.clear()
|
|
except asyncio.TimeoutError:
|
|
continue
|
|
|
|
return results
|
|
|
|
|
|
# ============================================================
|
|
# 同步包装器(供 stdio MCP 调用)
|
|
# ============================================================
|
|
|
|
_loop = None
|
|
|
|
|
|
def get_loop():
|
|
global _loop
|
|
if _loop is None or _loop.is_closed():
|
|
_loop = asyncio.new_event_loop()
|
|
asyncio.set_event_loop(_loop)
|
|
return _loop
|
|
|
|
|
|
def sync_send_private_message(user_id: int, text: str = "", file_paths: list = None):
|
|
loop = get_loop()
|
|
result = loop.run_until_complete(send_private_message(user_id, text, file_paths or []))
|
|
loop.run_until_complete(_ws.close())
|
|
return result
|
|
|
|
|
|
def sync_send_group_message(group_id: int, text: str = "", file_paths: list = None):
|
|
loop = get_loop()
|
|
result = loop.run_until_complete(send_group_message(group_id, text, file_paths or []))
|
|
loop.run_until_complete(_ws.close())
|
|
return result
|
|
|
|
|
|
def sync_receive_messages(count: int = 1, user_id: int = None,
|
|
group_id: int = None, timeout: int = RECEIVE_TIMEOUT):
|
|
loop = get_loop()
|
|
result = loop.run_until_complete(receive_messages(count, user_id, group_id, timeout))
|
|
loop.run_until_complete(_ws.close())
|
|
return result
|
|
|
|
|
|
# ============================================================
|
|
# MCP JSON-RPC 处理
|
|
# ============================================================
|
|
|
|
TOOLS = [
|
|
{
|
|
"name": "send_private_message",
|
|
"description": "向指定 QQ 用户发送私聊消息,可附带任意类型的文件(图片/视频/文档等)。",
|
|
"inputSchema": {
|
|
"type": "object",
|
|
"properties": {
|
|
"user_id": {"type": "number", "description": "目标 QQ 号"},
|
|
"text": {"type": "string", "description": "要发送的文字内容(可为空,但需要至少有文字或媒体文件之一)"},
|
|
"file_paths": {
|
|
"type": "array",
|
|
"items": {"type": "string"},
|
|
"description": "文件的本地绝对路径列表,图片用 image 类型,视频用 video 类型,其余后缀一律用 file 类型发送"
|
|
}
|
|
},
|
|
"required": ["user_id"]
|
|
}
|
|
},
|
|
{
|
|
"name": "send_group_message",
|
|
"description": "向指定 QQ 群发送消息,可附带任意类型的文件(图片/视频/文档等)。",
|
|
"inputSchema": {
|
|
"type": "object",
|
|
"properties": {
|
|
"group_id": {"type": "number", "description": "目标群号"},
|
|
"text": {"type": "string", "description": "要发送的文字内容(可为空,但需要至少有文字或媒体文件之一)"},
|
|
"file_paths": {
|
|
"type": "array",
|
|
"items": {"type": "string"},
|
|
"description": "文件的本地绝对路径列表,图片用 image 类型,视频用 video 类型,其余后缀一律用 file 类型发送"
|
|
}
|
|
},
|
|
"required": ["group_id"]
|
|
}
|
|
},
|
|
{
|
|
"name": "receive_messages",
|
|
"description": "接收来自 NapCat 的消息,可按 QQ 号或群号过滤,接收指定条数后返回(有超时限制)。",
|
|
"inputSchema": {
|
|
"type": "object",
|
|
"properties": {
|
|
"count": {"type": "number", "description": "要接收的消息条数,默认 1"},
|
|
"user_id": {"type": "number", "description": "按 QQ 号过滤(可选,不传则不过滤)"},
|
|
"group_id": {"type": "number", "description": "按群号过滤(可选,不传则不过滤)"},
|
|
"timeout": {"type": "number", "description": "超时秒数,默认 30"}
|
|
},
|
|
"required": []
|
|
}
|
|
}
|
|
]
|
|
|
|
TOOL_MAP = {
|
|
"send_private_message": sync_send_private_message,
|
|
"send_group_message": sync_send_group_message,
|
|
"receive_messages": sync_receive_messages
|
|
}
|
|
|
|
|
|
def handle_request(req):
|
|
method = req.get("method", "")
|
|
req_id = req.get("id")
|
|
|
|
if method == "initialize":
|
|
return {
|
|
"jsonrpc": "2.0",
|
|
"id": req_id,
|
|
"result": {
|
|
"protocolVersion": "2024-11-05",
|
|
"capabilities": {
|
|
"tools": {}
|
|
},
|
|
"serverInfo": {
|
|
"name": "ros-qq-messages",
|
|
"version": "1.0.0"
|
|
}
|
|
}
|
|
}
|
|
|
|
if method == "ping":
|
|
return {"jsonrpc": "2.0", "id": req_id, "result": {}}
|
|
|
|
if method == "notifications/initialized":
|
|
return None
|
|
|
|
if method == "tools/list":
|
|
return {"jsonrpc": "2.0", "id": req_id, "result": {"tools": TOOLS}}
|
|
|
|
if method == "tools/call":
|
|
tool_name = req["params"]["name"]
|
|
arguments = req["params"].get("arguments", {})
|
|
tool_func = TOOL_MAP.get(tool_name)
|
|
if not tool_func:
|
|
return {"jsonrpc": "2.0", "id": req_id,
|
|
"error": {"code": -32601, "message": f"未知工具: {tool_name}"}}
|
|
try:
|
|
result = tool_func(**arguments)
|
|
text = json.dumps(result, ensure_ascii=False, indent=2)
|
|
return {"jsonrpc": "2.0", "id": req_id,
|
|
"result": {"content": [{"type": "text", "text": text}]}}
|
|
except Exception as e:
|
|
return {"jsonrpc": "2.0", "id": req_id,
|
|
"error": {"code": -32000, "message": str(e)}}
|
|
|
|
return {"jsonrpc": "2.0", "id": req_id,
|
|
"error": {"code": -32601, "message": f"未知方法: {method}"}}
|
|
|
|
|
|
def main():
|
|
for line in sys.stdin:
|
|
line = line.strip()
|
|
if not line:
|
|
continue
|
|
try:
|
|
req = json.loads(line)
|
|
resp = handle_request(req)
|
|
if resp is None:
|
|
continue
|
|
sys.stdout.write(json.dumps(resp, ensure_ascii=False) + '\n')
|
|
sys.stdout.flush()
|
|
except json.JSONDecodeError:
|
|
pass
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|