commit 74402b5e5562e459eedd1db774b81bd01367bc5e Author: RRRosmontis Date: Thu Aug 27 21:43:28 2026 +0800 MCP 服务:ds 余额查询 + ros 邮件/QQ/服务器状态 diff --git a/ds_balance_inquiry.py b/ds_balance_inquiry.py new file mode 100644 index 0000000..1927464 --- /dev/null +++ b/ds_balance_inquiry.py @@ -0,0 +1,141 @@ +import json +import os +import sys +import requests + +BASE_URL = 'https://api.deepseek.com' + + +# ============================================================ +# 内部辅助 +# ============================================================ + +def resolve_api_key(arguments): + """解析 API Key,优先级:参数 > DEEPSEEK_API_KEY 环境变量""" + if arguments.get('api_key'): + return arguments['api_key'] + + key = os.environ.get('DEEPSEEK_API_KEY', '') + if key: + return key + + return None + + +# ============================================================ +# 工具函数 +# ============================================================ + +def get_user_balance(api_key=None): + """查询 DeepSeek 账户余额""" + key = resolve_api_key({'api_key': api_key} if api_key else {}) + if not key: + raise Exception( + '未找到 API Key,请通过以下任一方式提供:\n' + '1. 调用工具时传入 api_key 参数\n' + '2. 设置环境变量 DEEPSEEK_API_KEY' + ) + + headers = { + 'Authorization': f'Bearer {key}', + 'Accept': 'application/json', + } + resp = requests.get(f'{BASE_URL}/user/balance', headers=headers, timeout=15) + if resp.status_code != 200: + raise Exception(f'API 请求失败 (HTTP {resp.status_code}): {resp.text}') + return resp.json() + + +# ============================================================ +# MCP JSON-RPC 处理 +# ============================================================ + +TOOLS = [ + { + "name": "get_user_balance", + "description": "查询 DeepSeek API 账户余额,返回各币种的可用余额、赠送余额和充值余额。支持从参数或环境变量 DEEPSEEK_API_KEY 读取 API Key。", + "inputSchema": { + "type": "object", + "properties": { + "api_key": { + "type": "string", + "description": "DeepSeek API Key(可选,不传则从环境变量 DEEPSEEK_API_KEY 读取)" + } + }, + "required": [] + } + } +] + +TOOL_MAP = { + "get_user_balance": get_user_balance +} + + +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": "ds-balance-inquiry", + "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() diff --git a/ros_mail_mcp.py b/ros_mail_mcp.py new file mode 100644 index 0000000..1c5343a --- /dev/null +++ b/ros_mail_mcp.py @@ -0,0 +1,487 @@ +import imaplib +import smtplib +import ssl +import email +import re +import time +import json +import os +import sys +from email.header import decode_header, Header +from email.mime.text import MIMEText +from email.mime.multipart import MIMEMultipart + +IMAP_HOST = 'mail.rrrosmontis.icu' +IMAP_PORT = 993 +SMTP_HOST = 'mail.rrrosmontis.icu' +SMTP_PORT = 465 + + +def decode_mime_str(s): + if not s: + return '' + parts = decode_header(s) + decoded_parts = [] + for part, charset in parts: + if isinstance(part, bytes): + try: + if charset: + decoded_parts.append(part.decode(charset, errors='replace')) + else: + decoded_parts.append(part.decode('utf-8', errors='replace')) + except Exception: + decoded_parts.append(part.decode('utf-8', errors='replace')) + else: + decoded_parts.append(part) + return ''.join(decoded_parts) + + +def decode_attachment_names(raw): + if not raw: + return [] + parts = decode_header(raw) + decoded_parts = [] + for part, charset in parts: + if isinstance(part, bytes): + try: + if charset: + decoded_parts.append(part.decode(charset, errors='replace')) + else: + decoded_parts.append(part.decode('utf-8', errors='replace')) + except Exception: + decoded_parts.append(part.decode('utf-8', errors='replace')) + else: + decoded_parts.append(part) + combined = ''.join(decoded_parts) + return [name.strip() for name in combined.split(',') if name.strip()] + + +def extract_email_body(msg): + if msg.is_multipart(): + for part in msg.walk(): + if part.get_content_type() == "text/html": + payload = part.get_payload(decode=True) + charset = part.get_content_charset() or 'utf-8' + try: + return payload.decode(charset, errors='replace') + except Exception: + return payload.decode('utf-8', errors='replace') + for part in msg.walk(): + if part.get_content_type() == "text/plain": + payload = part.get_payload(decode=True) + charset = part.get_content_charset() or 'utf-8' + try: + return payload.decode(charset, errors='replace') + except Exception: + return payload.decode('utf-8', errors='replace') + else: + payload = msg.get_payload(decode=True) + charset = msg.get_content_charset() or 'utf-8' + try: + return payload.decode(charset, errors='replace') + except Exception: + return payload.decode('utf-8', errors='replace') + return '' + + +def imap_connect(email_addr, password): + context = ssl._create_unverified_context() + conn = imaplib.IMAP4_SSL(IMAP_HOST, IMAP_PORT, ssl_context=context) + conn.login(email_addr, password) + return conn + + +def smtp_connect(email_addr, password): + context = ssl._create_unverified_context() + smtp = smtplib.SMTP_SSL(SMTP_HOST, SMTP_PORT, context=context) + smtp.ehlo() + smtp.login(email_addr, password) + return smtp + + +def find_sent_folder(conn): + typ, folder_data = conn.list() + if typ != 'OK': + return None + sent_candidates = ['Sent', 'Sent Messages', 'Sent Items', + '\u5df2\u53d1\u9001', '\u5df2\u53d1\u9001\u90ae\u4ef6'] + for line in folder_data: + line_str = line.decode() if isinstance(line, bytes) else line + folder_name = None + match = re.search(r'"([^"]+)"$', line_str) + if match: + folder_name = match.group(1) + else: + parts = line_str.split() + if parts: + folder_name = parts[-1].strip('"') + if folder_name: + if any(folder_name.lower() == cand.lower() for cand in sent_candidates): + return folder_name + if 'sent' in folder_name.lower() or '\u5df2\u53d1\u9001' in folder_name: + return folder_name + return 'Sent' + + +def list_mailboxes(email_addr, password): + conn = imap_connect(email_addr, password) + try: + typ, data = conn.list() + if typ != 'OK': + return [] + mailboxes = [] + for line in data: + line_str = line.decode() if isinstance(line, bytes) else line + match = re.search(r'"([^"]+)"$', line_str) + if match: + mailboxes.append(match.group(1)) + return mailboxes + finally: + conn.logout() + + +def list_messages(email_addr, password, folder='INBOX', page=1, per_page=20): + conn = imap_connect(email_addr, password) + try: + conn.select(folder) + result, data = conn.uid('search', None, "ALL") + if result != 'OK': + return [], False + + uid_list = data[0].split() + total = len(uid_list) + + start = max(0, total - page * per_page) + end = total - (page - 1) * per_page + page_uids = uid_list[start:end] if start < end else [] + + messages = [] + for uid in page_uids: + result, msg_data = conn.uid('fetch', uid, '(BODY.PEEK[HEADER])') + if result != 'OK': + continue + raw_header = msg_data[0][1] + msg = email.message_from_bytes(raw_header) + messages.append({ + 'uid': uid.decode() if isinstance(uid, bytes) else uid, + 'from': decode_mime_str(msg.get('From', '')), + 'subject': decode_mime_str(msg.get('Subject', '')), + 'date': msg.get('Date', ''), + 'attachments': decode_attachment_names(msg.get('X-Attachments', '')) + }) + + messages.reverse() + return messages, page * per_page < total + finally: + conn.logout() + + +def read_message(email_addr, password, uid, folder='INBOX'): + conn = imap_connect(email_addr, password) + try: + conn.select(folder) + result, data = conn.uid('fetch', uid, '(RFC822)') + if result != 'OK': + return None + raw_email = data[0][1] + msg = email.message_from_bytes(raw_email) + return { + 'uid': uid, + 'body': extract_email_body(msg), + 'subject': decode_mime_str(msg.get('Subject', '')), + 'from': decode_mime_str(msg.get('From', '')), + 'to': decode_mime_str(msg.get('To', '')), + 'date': msg.get('Date', ''), + 'attachments': decode_attachment_names(msg.get('X-Attachments', '')) + } + finally: + conn.logout() + + +def list_sent(email_addr, password, page=1, per_page=20): + conn = imap_connect(email_addr, password) + try: + sent_folder = find_sent_folder(conn) + if not sent_folder: + return [], False + try: + conn.select(sent_folder) + except Exception: + return [], False + + result, data = conn.uid('search', None, "ALL") + if result != 'OK': + return [], False + + uid_list = data[0].split() + total = len(uid_list) + + start = max(0, total - page * per_page) + end = total - (page - 1) * per_page + page_uids = uid_list[start:end] if start < end else [] + + messages = [] + for uid in page_uids: + result, msg_data = conn.uid('fetch', uid, '(BODY.PEEK[HEADER])') + if result != 'OK': + continue + raw_header = msg_data[0][1] + msg = email.message_from_bytes(raw_header) + messages.append({ + 'uid': uid.decode() if isinstance(uid, bytes) else uid, + 'from': decode_mime_str(msg.get('From', '')), + 'to': decode_mime_str(msg.get('To', '')), + 'subject': decode_mime_str(msg.get('Subject', '')), + 'date': msg.get('Date', ''), + 'attachments': decode_attachment_names(msg.get('X-Attachments', '')) + }) + + messages.reverse() + return messages, page * per_page < total + finally: + conn.logout() + + +def send_email(email_addr, password, to_addr, subject, body): + smtp = smtp_connect(email_addr, password) + try: + msg = MIMEMultipart() + msg['From'] = email_addr + msg['To'] = to_addr + msg['Subject'] = subject + msg.attach(MIMEText(body, 'html', 'utf-8')) + smtp.send_message(msg) + smtp.quit() + + # 保存到已发送 + imap_conn = imap_connect(email_addr, password) + try: + sent_folder = find_sent_folder(imap_conn) + if sent_folder: + try: + imap_conn.select(sent_folder) + except Exception: + try: + imap_conn.create(sent_folder) + imap_conn.select(sent_folder) + except Exception: + return True + imap_conn.append(sent_folder, '\\Seen', + imaplib.Time2Internaldate(time.time()), + msg.as_bytes()) + imap_conn.close() + finally: + imap_conn.logout() + return True + except Exception as e: + return str(e) + + +def delete_message(email_addr, password, uid, folder='INBOX'): + conn = imap_connect(email_addr, password) + try: + conn.select(folder) + result, _ = conn.uid('store', uid, '+FLAGS', '\\Deleted') + if result != 'OK': + return False + conn.expunge() + return True + finally: + conn.logout() + + +# ============================================================ +# MCP JSON-RPC 处理 +# ============================================================ + +TOOLS = [ + { + "name": "list_mailboxes", + "description": "列出 IMAP 邮箱中的所有文件夹", + "inputSchema": { + "type": "object", + "properties": { + "email": {"type": "string", "description": "邮箱地址"}, + "password": {"type": "string", "description": "邮箱密码,不传则从环境变量 ROS_MAIL_MCP_PASSWORD 读取"} + }, + "required": ["email"] + } + }, + { + "name": "list_messages", + "description": "列出指定文件夹中的邮件(默认 INBOX),支持分页", + "inputSchema": { + "type": "object", + "properties": { + "email": {"type": "string", "description": "邮箱地址"}, + "password": {"type": "string", "description": "邮箱密码,不传则从环境变量 ROS_MAIL_MCP_PASSWORD 读取"}, + "folder": {"type": "string", "description": "文件夹名,默认 INBOX"}, + "page": {"type": "integer", "description": "页码,从 1 开始,默认 1"}, + "per_page": {"type": "integer", "description": "每页数量,默认 20"} + }, + "required": ["email"] + } + }, + { + "name": "read_message", + "description": "读取指定 UID 的邮件全文", + "inputSchema": { + "type": "object", + "properties": { + "email": {"type": "string", "description": "邮箱地址"}, + "password": {"type": "string", "description": "邮箱密码,不传则从环境变量 ROS_MAIL_MCP_PASSWORD 读取"}, + "uid": {"type": "string", "description": "邮件 UID"}, + "folder": {"type": "string", "description": "文件夹名,默认 INBOX"} + }, + "required": ["email", "uid"] + } + }, + { + "name": "list_sent", + "description": "列出已发送邮件,支持分页", + "inputSchema": { + "type": "object", + "properties": { + "email": {"type": "string", "description": "邮箱地址"}, + "password": {"type": "string", "description": "邮箱密码,不传则从环境变量 ROS_MAIL_MCP_PASSWORD 读取"}, + "page": {"type": "integer", "description": "页码,从 1 开始,默认 1"}, + "per_page": {"type": "integer", "description": "每页数量,默认 20"} + }, + "required": ["email"] + } + }, + { + "name": "send_email", + "description": "发送邮件(HTML 格式),发送后会保存到已发送文件夹", + "inputSchema": { + "type": "object", + "properties": { + "email": {"type": "string", "description": "发件人邮箱地址"}, + "password": {"type": "string", "description": "发件人邮箱密码"}, + "to": {"type": "string", "description": "收件人邮箱地址"}, + "subject": {"type": "string", "description": "邮件主题"}, + "body": {"type": "string", "description": "邮件正文(HTML 格式)"} + }, + "required": ["email", "to", "subject", "body"] + } + }, + { + "name": "delete_message", + "description": "删除指定 UID 的邮件", + "inputSchema": { + "type": "object", + "properties": { + "email": {"type": "string", "description": "邮箱地址"}, + "password": {"type": "string", "description": "邮箱密码,不传则从环境变量 ROS_MAIL_MCP_PASSWORD 读取"}, + "uid": {"type": "string", "description": "邮件 UID"}, + "folder": {"type": "string", "description": "文件夹名,默认 INBOX"} + }, + "required": ["email", "uid"] + } + } +] + +TOOL_MAP = { + "list_mailboxes": list_mailboxes, + "list_messages": list_messages, + "read_message": read_message, + "list_sent": list_sent, + "send_email": send_email, + "delete_message": delete_message +} + + +def resolve_password(arguments): + if arguments.get('password'): + return arguments['password'] + pw = os.environ.get('ROS_MAIL_MCP_PASSWORD', '') + if pw: + return pw + return None + + +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-mail-mcp", + "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}"}} + password = resolve_password(arguments) + if not password: + return {"jsonrpc": "2.0", "id": req_id, + "error": {"code": -32602, "message": "缺少密码: 请在参数中传入 password 或设置环境变量 ROS_MAIL_MCP_PASSWORD"}} + arguments['password'] = password + # 参数名映射:schema 中的参数名 -> Python 函数参数名 + if 'email' in arguments and 'email_addr' not in arguments: + arguments['email_addr'] = arguments.pop('email') + if 'to' in arguments and 'to_addr' not in arguments: + arguments['to_addr'] = arguments.pop('to') + try: + result = tool_func(**arguments) + if isinstance(result, tuple): + result = {"data": result[0], "has_more": result[1]} + elif isinstance(result, list): + result = {"mailboxes": result} + elif isinstance(result, dict): + pass + elif isinstance(result, bool): + result = {"success": result} + 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() diff --git a/ros_qq_messages.py b/ros_qq_messages.py new file mode 100644 index 0000000..5c5ae90 --- /dev/null +++ b/ros_qq_messages.py @@ -0,0 +1,311 @@ +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() diff --git a/ros_servers_status.py b/ros_servers_status.py new file mode 100644 index 0000000..15c6570 --- /dev/null +++ b/ros_servers_status.py @@ -0,0 +1,270 @@ +import json +import os +import sys +import requests + +BASE_URL = 'https://status.rrrosmontis.icu' + +# ============================================================ +# 内部辅助 +# ============================================================ + +_session = None + + +def get_session(): + """获取或创建带 cookie 的 requests Session""" + global _session + if _session is None: + _session = requests.Session() + return _session + + +def ensure_login(): + """确保已登录,如果未登录则自动登录""" + s = get_session() + resp = s.get(f'{BASE_URL}/api/me', timeout=10) + data = resp.json() + if data.get('logged_in'): + return + account = os.environ.get('ROS_SERVERS_STATUS_ACCOUNT', '') + password = os.environ.get('ROS_SERVERS_STATUS_PASSWORD', '') + if not account or not password: + raise Exception('未登录,且环境变量 ROS_SERVERS_STATUS_ACCOUNT / ROS_SERVERS_STATUS_PASSWORD 未设置') + payload = {'username': account, 'password': password} + resp = s.post(f'{BASE_URL}/api/login', json=payload, timeout=10) + result = resp.json() + if result.get('status') != 'success': + raise Exception(f"登录失败: {result.get('message', '未知错误')}") + + +def api_get(path, params=None, need_auth=True): + if need_auth: + ensure_login() + s = get_session() + resp = s.get(f'{BASE_URL}{path}', params=params, timeout=15) + return resp.json() + + +# ============================================================ +# 工具函数 +# ============================================================ + +def get_nodes(): + """获取所有节点基本信息列表""" + return api_get('/api/nodes') + + +def get_node_recent(uuid): + """获取指定节点最近1分钟实时状态(嵌套对象结构)""" + return api_get(f'/api/recent/{uuid}') + + +def get_load_records(uuid, hours=4, load_type='all'): + """获取指定节点负载历史记录(扁平化结构)""" + params = {'uuid': uuid, 'hours': hours} + if load_type and load_type != 'all': + params['load_type'] = load_type + return api_get('/api/records/load', params=params) + + +def get_ping_records(uuid=None, task_id=None, hours=4): + """获取 Ping 历史记录,uuid 和 task_id 至少提供一个""" + params = {'hours': hours} + if uuid: + params['uuid'] = uuid + if task_id is not None: + params['task_id'] = task_id + return api_get('/api/records/ping', params=params) + + +def get_ping_tasks(): + """获取公开的 Ping 任务配置列表""" + return api_get('/api/task/ping') + + +def get_site_info(): + """获取站点公开设置属性""" + return api_get('/api/public', need_auth=False) + + +def get_version(): + """获取 Komari 服务端版本信息""" + return api_get('/api/version', need_auth=False) + + +def check_login(): + """检查当前登录状态""" + s = get_session() + resp = s.get(f'{BASE_URL}/api/me', timeout=10) + return resp.json() + + +# ============================================================ +# MCP JSON-RPC 处理 +# ============================================================ + +TOOLS = [ + { + "name": "get_nodes", + "description": "获取所有节点的基本信息列表(名称、CPU、内存、磁盘、价格、到期时间、流量限制等)。未登录时 hidden=true 的节点不可见。", + "inputSchema": { + "type": "object", + "properties": {}, + "required": [] + } + }, + { + "name": "get_node_recent", + "description": "获取指定节点最近1分钟的实时状态数据(CPU/内存/磁盘/网络/负载/连接数/GPU/运行时间),数据结构为嵌套对象格式。", + "inputSchema": { + "type": "object", + "properties": { + "uuid": {"type": "string", "description": "节点的唯一标识符 UUID"} + }, + "required": ["uuid"] + } + }, + { + "name": "get_load_records", + "description": "获取指定节点的负载历史记录(扁平化结构),可按指标类型筛选,用于生成图表和趋势分析。", + "inputSchema": { + "type": "object", + "properties": { + "uuid": {"type": "string", "description": "节点的唯一标识符 UUID"}, + "hours": {"type": "number", "description": "查询时间范围(小时),默认 4"}, + "load_type": {"type": "string", "description": "指标筛选类型: cpu / ram / swap / load / temp / disk / network / process / connections / all(默认)"} + }, + "required": ["uuid"] + } + }, + { + "name": "get_ping_records", + "description": "获取 Ping 历史记录。uuid 和 task_id 至少提供一个:仅 uuid 返回该节点参与的任务记录,仅 task_id 返回该任务下所有节点记录,同时提供则交集查询。", + "inputSchema": { + "type": "object", + "properties": { + "uuid": {"type": "string", "description": "节点 UUID(可选,与 task_id 至少提供一个)"}, + "task_id": {"type": "number", "description": "Ping 任务 ID(可选,与 uuid 至少提供一个)"}, + "hours": {"type": "number", "description": "查询时间范围(小时),默认 4"} + }, + "required": [] + } + }, + { + "name": "get_ping_tasks", + "description": "获取公开的 Ping 任务配置列表,包含任务名称、类型、执行间隔、关联节点等。", + "inputSchema": { + "type": "object", + "properties": {}, + "required": [] + } + }, + { + "name": "get_site_info", + "description": "获取站点公开设置(站点名、描述、主题、OAuth 配置、CORS、记录保留时间等),无需登录。", + "inputSchema": { + "type": "object", + "properties": {}, + "required": [] + } + }, + { + "name": "get_version", + "description": "获取 Komari 服务端版本号和 Git Hash,无需登录。", + "inputSchema": { + "type": "object", + "properties": {}, + "required": [] + } + }, + { + "name": "check_login", + "description": "检查当前登录状态,返回用户名、UUID、是否启用2FA、SSO 绑定等信息。", + "inputSchema": { + "type": "object", + "properties": {}, + "required": [] + } + } +] + +TOOL_MAP = { + "get_nodes": get_nodes, + "get_node_recent": get_node_recent, + "get_load_records": get_load_records, + "get_ping_records": get_ping_records, + "get_ping_tasks": get_ping_tasks, + "get_site_info": get_site_info, + "get_version": get_version, + "check_login": check_login +} + + +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-servers-status", + "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()