402 lines
13 KiB
Python
402 lines
13 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
Funstat BOT MCP Server
|
||
|
||
基于 Telethon 的 MCP 服务器,用于与 @openaiw_bot 交互
|
||
提供搜索、查询、统计等功能
|
||
"""
|
||
|
||
import asyncio
|
||
import json
|
||
import logging
|
||
import os
|
||
import time
|
||
from typing import Any, Dict, List, Optional
|
||
from datetime import datetime, timedelta
|
||
from collections import deque
|
||
|
||
from mcp.server import Server
|
||
from mcp.types import (
|
||
Resource,
|
||
Tool,
|
||
TextContent,
|
||
ImageContent,
|
||
EmbeddedResource,
|
||
)
|
||
from pydantic import AnyUrl
|
||
from telethon import TelegramClient
|
||
from telethon.tl.types import Message
|
||
|
||
# 配置日志
|
||
logging.basicConfig(
|
||
level=logging.INFO,
|
||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||
)
|
||
logger = logging.getLogger("funstat_mcp")
|
||
|
||
# 配置
|
||
API_ID = 24660516
|
||
API_HASH = "eae564578880a59c9963916ff1bbbd3a"
|
||
# Session 文件路径 - 使用独立的安全目录,防止被意外删除
|
||
SESSION_PATH = os.path.expanduser("~/telegram_sessions/funstat_bot")
|
||
BOT_USERNAME = "@openaiw_bot"
|
||
|
||
# 速率限制配置
|
||
RATE_LIMIT_PER_SECOND = 18 # 每秒最多18个请求
|
||
RATE_LIMIT_WINDOW = 1.0 # 1秒时间窗口
|
||
|
||
# 缓存配置
|
||
CACHE_TTL = 3600 # 缓存1小时
|
||
|
||
|
||
class RateLimiter:
|
||
"""速率限制器"""
|
||
|
||
def __init__(self, max_requests: int, time_window: float):
|
||
self.max_requests = max_requests
|
||
self.time_window = time_window
|
||
self.requests = deque()
|
||
|
||
async def acquire(self):
|
||
"""获取请求许可,如果超过限制则等待"""
|
||
now = time.time()
|
||
|
||
# 移除超出时间窗口的请求记录
|
||
while self.requests and self.requests[0] < now - self.time_window:
|
||
self.requests.popleft()
|
||
|
||
# 如果达到限制,等待
|
||
if len(self.requests) >= self.max_requests:
|
||
sleep_time = self.requests[0] + self.time_window - now
|
||
if sleep_time > 0:
|
||
logger.info(f"速率限制: 等待 {sleep_time:.2f} 秒")
|
||
await asyncio.sleep(sleep_time)
|
||
return await self.acquire() # 递归重试
|
||
|
||
# 记录请求时间
|
||
self.requests.append(now)
|
||
|
||
|
||
class ResponseCache:
|
||
"""响应缓存"""
|
||
|
||
def __init__(self, ttl: int = CACHE_TTL):
|
||
self.cache: Dict[str, tuple[Any, float]] = {}
|
||
self.ttl = ttl
|
||
|
||
def get(self, key: str) -> Optional[Any]:
|
||
"""获取缓存"""
|
||
if key in self.cache:
|
||
value, timestamp = self.cache[key]
|
||
if time.time() - timestamp < self.ttl:
|
||
logger.info(f"缓存命中: {key}")
|
||
return value
|
||
else:
|
||
# 过期,删除
|
||
del self.cache[key]
|
||
return None
|
||
|
||
def set(self, key: str, value: Any):
|
||
"""设置缓存"""
|
||
self.cache[key] = (value, time.time())
|
||
logger.info(f"缓存保存: {key}")
|
||
|
||
def clear_expired(self):
|
||
"""清理过期缓存"""
|
||
now = time.time()
|
||
expired_keys = [
|
||
key for key, (_, timestamp) in self.cache.items()
|
||
if now - timestamp >= self.ttl
|
||
]
|
||
for key in expired_keys:
|
||
del self.cache[key]
|
||
if expired_keys:
|
||
logger.info(f"清理了 {len(expired_keys)} 个过期缓存")
|
||
|
||
|
||
class FunstatMCPServer:
|
||
"""Funstat MCP 服务器"""
|
||
|
||
def __init__(self):
|
||
self.server = Server("funstat-mcp")
|
||
self.client: Optional[TelegramClient] = None
|
||
self.bot_entity = None
|
||
self.rate_limiter = RateLimiter(RATE_LIMIT_PER_SECOND, RATE_LIMIT_WINDOW)
|
||
self.cache = ResponseCache()
|
||
|
||
# 注册处理器
|
||
self.server.list_tools()(self.list_tools)
|
||
self.server.call_tool()(self.call_tool)
|
||
|
||
async def initialize(self):
|
||
"""初始化 Telegram 客户端"""
|
||
logger.info("初始化 Telegram 客户端...")
|
||
|
||
# 检查 session 文件
|
||
session_file = f"{SESSION_PATH}.session"
|
||
if not os.path.exists(session_file):
|
||
raise FileNotFoundError(
|
||
f"Session 文件不存在: {session_file}\n"
|
||
f"请先运行 create_session.py 创建 session 文件\n"
|
||
f"或者将现有 session 文件复制到: ~/telegram_sessions/"
|
||
)
|
||
|
||
logger.info(f"使用 Session 文件: {session_file}")
|
||
|
||
# 创建客户端
|
||
self.client = TelegramClient(SESSION_PATH, API_ID, API_HASH)
|
||
await self.client.start()
|
||
|
||
# 获取 bot 实体
|
||
logger.info(f"连接到 {BOT_USERNAME}...")
|
||
self.bot_entity = await self.client.get_entity(BOT_USERNAME)
|
||
logger.info(f"✅ 已连接到: {self.bot_entity.first_name}")
|
||
|
||
# 获取当前用户信息
|
||
me = await self.client.get_me()
|
||
logger.info(f"✅ 当前账号: @{me.username} (ID: {me.id})")
|
||
|
||
async def send_command_and_wait(
|
||
self,
|
||
command: str,
|
||
timeout: int = 10,
|
||
use_cache: bool = True
|
||
) -> str:
|
||
"""发送命令到 BOT 并等待响应"""
|
||
|
||
# 检查缓存
|
||
cache_key = f"cmd:{command}"
|
||
if use_cache:
|
||
cached = self.cache.get(cache_key)
|
||
if cached:
|
||
return cached
|
||
|
||
# 速率限制
|
||
await self.rate_limiter.acquire()
|
||
|
||
logger.info(f"📤 发送命令: {command}")
|
||
|
||
# 记录发送前的最新消息 ID
|
||
last_message_id = 0
|
||
async for message in self.client.iter_messages(self.bot_entity, limit=1):
|
||
last_message_id = message.id
|
||
break
|
||
|
||
# 发送消息
|
||
send_time = datetime.now()
|
||
await self.client.send_message(self.bot_entity, command)
|
||
|
||
# 等待响应(稍等一下让 BOT 有时间响应)
|
||
await asyncio.sleep(1.5)
|
||
|
||
# 获取新消息
|
||
start_time = time.time()
|
||
while time.time() - start_time < timeout:
|
||
# 获取最新消息
|
||
async for message in self.client.iter_messages(self.bot_entity, limit=5):
|
||
# 检查是否是新消息
|
||
if message.id > last_message_id:
|
||
# 检查是否是 BOT 的消息
|
||
if not message.out and message.text:
|
||
response_text = message.text
|
||
logger.info(f"✅ 收到响应 ({len(response_text)} 字符)")
|
||
|
||
# 保存到缓存
|
||
if use_cache:
|
||
self.cache.set(cache_key, response_text)
|
||
|
||
return response_text
|
||
|
||
# 继续等待
|
||
await asyncio.sleep(0.5)
|
||
|
||
raise TimeoutError(f"等待 BOT 响应超时 ({timeout}秒)")
|
||
|
||
async def list_tools(self) -> List[Tool]:
|
||
"""列出所有可用工具"""
|
||
return [
|
||
Tool(
|
||
name="funstat_search",
|
||
description="搜索 Telegram 群组、频道。支持关键词搜索,返回相关的群组列表",
|
||
inputSchema={
|
||
"type": "object",
|
||
"properties": {
|
||
"query": {
|
||
"type": "string",
|
||
"description": "搜索关键词,例如: 'python', '区块链', 'AI'"
|
||
}
|
||
},
|
||
"required": ["query"]
|
||
}
|
||
),
|
||
Tool(
|
||
name="funstat_topchat",
|
||
description="获取热门群组/频道列表,按成员数或活跃度排序",
|
||
inputSchema={
|
||
"type": "object",
|
||
"properties": {
|
||
"category": {
|
||
"type": "string",
|
||
"description": "分类筛选(可选),例如: 'tech', 'crypto', 'news'"
|
||
}
|
||
}
|
||
}
|
||
),
|
||
Tool(
|
||
name="funstat_text",
|
||
description="通过消息文本搜索,查找包含特定文本的消息和来源群组",
|
||
inputSchema={
|
||
"type": "object",
|
||
"properties": {
|
||
"text": {
|
||
"type": "string",
|
||
"description": "要搜索的文本内容"
|
||
}
|
||
},
|
||
"required": ["text"]
|
||
}
|
||
),
|
||
Tool(
|
||
name="funstat_human",
|
||
description="通过姓名搜索用户,查找 Telegram 用户信息",
|
||
inputSchema={
|
||
"type": "object",
|
||
"properties": {
|
||
"name": {
|
||
"type": "string",
|
||
"description": "用户姓名"
|
||
}
|
||
},
|
||
"required": ["name"]
|
||
}
|
||
),
|
||
Tool(
|
||
name="funstat_user_info",
|
||
description="查询用户详细信息,支持通过用户名、用户ID、联系人等方式查询",
|
||
inputSchema={
|
||
"type": "object",
|
||
"properties": {
|
||
"identifier": {
|
||
"type": "string",
|
||
"description": "用户标识: 用户名(@username)、用户ID、或手机号"
|
||
}
|
||
},
|
||
"required": ["identifier"]
|
||
}
|
||
),
|
||
Tool(
|
||
name="funstat_balance",
|
||
description="查询当前账号的积分余额和使用统计",
|
||
inputSchema={
|
||
"type": "object",
|
||
"properties": {}
|
||
}
|
||
),
|
||
Tool(
|
||
name="funstat_menu",
|
||
description="显示 funstat BOT 的主菜单和所有可用功能",
|
||
inputSchema={
|
||
"type": "object",
|
||
"properties": {}
|
||
}
|
||
),
|
||
Tool(
|
||
name="funstat_start",
|
||
description="获取 funstat BOT 的欢迎信息和使用说明",
|
||
inputSchema={
|
||
"type": "object",
|
||
"properties": {}
|
||
}
|
||
)
|
||
]
|
||
|
||
async def call_tool(self, name: str, arguments: Dict[str, Any]) -> List[TextContent]:
|
||
"""调用工具"""
|
||
logger.info(f"🔧 调用工具: {name} with {arguments}")
|
||
|
||
try:
|
||
if name == "funstat_search":
|
||
query = arguments["query"]
|
||
response = await self.send_command_and_wait(f"/search {query}")
|
||
return [TextContent(type="text", text=response)]
|
||
|
||
elif name == "funstat_topchat":
|
||
category = arguments.get("category", "")
|
||
if category:
|
||
response = await self.send_command_and_wait(f"/topchat {category}")
|
||
else:
|
||
response = await self.send_command_and_wait("/topchat")
|
||
return [TextContent(type="text", text=response)]
|
||
|
||
elif name == "funstat_text":
|
||
text = arguments["text"]
|
||
response = await self.send_command_and_wait(f"/text {text}")
|
||
return [TextContent(type="text", text=response)]
|
||
|
||
elif name == "funstat_human":
|
||
name_query = arguments["name"]
|
||
response = await self.send_command_and_wait(f"/human {name_query}")
|
||
return [TextContent(type="text", text=response)]
|
||
|
||
elif name == "funstat_user_info":
|
||
identifier = arguments["identifier"]
|
||
# 直接发送用户标识(用户名、ID等)
|
||
response = await self.send_command_and_wait(identifier)
|
||
return [TextContent(type="text", text=response)]
|
||
|
||
elif name == "funstat_balance":
|
||
response = await self.send_command_and_wait("/balance")
|
||
return [TextContent(type="text", text=response)]
|
||
|
||
elif name == "funstat_menu":
|
||
response = await self.send_command_and_wait("/menu")
|
||
return [TextContent(type="text", text=response)]
|
||
|
||
elif name == "funstat_start":
|
||
response = await self.send_command_and_wait("/start")
|
||
return [TextContent(type="text", text=response)]
|
||
|
||
else:
|
||
raise ValueError(f"未知工具: {name}")
|
||
|
||
except Exception as e:
|
||
logger.error(f"❌ 工具调用失败: {e}")
|
||
return [TextContent(
|
||
type="text",
|
||
text=f"❌ 错误: {str(e)}"
|
||
)]
|
||
|
||
async def run(self):
|
||
"""运行服务器"""
|
||
await self.initialize()
|
||
|
||
# 启动定期清理过期缓存的任务
|
||
async def cache_cleanup_task():
|
||
while True:
|
||
await asyncio.sleep(300) # 每5分钟清理一次
|
||
self.cache.clear_expired()
|
||
|
||
asyncio.create_task(cache_cleanup_task())
|
||
|
||
logger.info("🚀 Funstat MCP Server 已启动")
|
||
|
||
# 运行 MCP 服务器
|
||
from mcp.server.stdio import stdio_server
|
||
|
||
async with stdio_server() as (read_stream, write_stream):
|
||
await self.server.run(
|
||
read_stream,
|
||
write_stream,
|
||
self.server.create_initialization_options()
|
||
)
|
||
|
||
|
||
async def main():
|
||
"""主函数"""
|
||
server = FunstatMCPServer()
|
||
await server.run()
|
||
|
||
|
||
if __name__ == "__main__":
|
||
asyncio.run(main())
|