chore: initial commit

This commit is contained in:
你的用户名
2025-11-01 21:58:03 +08:00
commit a05a7dd40e
65 changed files with 16590 additions and 0 deletions

481
core/server.py Normal file
View File

@@ -0,0 +1,481 @@
#!/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 = int(os.getenv("TELEGRAM_API_ID", "24660516"))
API_HASH = os.getenv("TELEGRAM_API_HASH", "eae564578880a59c9963916ff1bbbd3a")
# Session 文件路径 - 使用独立的安全目录,防止被意外删除
SESSION_PATH = os.path.expanduser(
os.getenv("TELEGRAM_SESSION_PATH", "~/telegram_sessions/funstat_bot")
)
BOT_USERNAME = os.getenv("FUNSTAT_BOT_USERNAME", "@openaiw_bot")
PROXY_TYPE = os.getenv("FUNSTAT_PROXY_TYPE", "socks5")
PROXY_HOST = os.getenv("FUNSTAT_PROXY_HOST")
PROXY_PORT = os.getenv("FUNSTAT_PROXY_PORT")
PROXY_USERNAME = os.getenv("FUNSTAT_PROXY_USERNAME")
PROXY_PASSWORD = os.getenv("FUNSTAT_PROXY_PASSWORD")
# 速率限制配置
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}")
proxy = None
if PROXY_HOST and PROXY_PORT:
try:
proxy_port = int(PROXY_PORT)
if PROXY_USERNAME:
proxy = (
PROXY_TYPE,
PROXY_HOST,
proxy_port,
PROXY_USERNAME,
PROXY_PASSWORD or ""
)
else:
proxy = (PROXY_TYPE, PROXY_HOST, proxy_port)
logger.info(
"使用代理连接: %s://%s:%s",
PROXY_TYPE,
PROXY_HOST,
proxy_port,
)
except ValueError:
logger.warning(
"代理端口无效,忽略代理配置: %s",
PROXY_PORT,
)
# 创建客户端
self.client = TelegramClient(SESSION_PATH, API_ID, API_HASH, proxy=proxy)
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"].strip()
if not identifier:
raise ValueError("用户标识不能为空")
# funstat BOT 需要显式的 /user_info 命令
response = await self.send_command_and_wait(f"/user_info {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 服务器 - Streamable HTTP 模式
from mcp.server.streamable_http import StreamableHTTPServerTransport
from starlette.applications import Starlette
from starlette.routing import Mount
import uvicorn
import uuid
# 是否启用会话校验
require_session = os.getenv("FUNSTAT_REQUIRE_SESSION", "false").lower() in ("1", "true", "yes")
# 创建 Streamable HTTP 传输(生成唯一 session ID默认关闭强校验以兼容旧客户端
session_id = str(uuid.uuid4()) if require_session else None
transport = StreamableHTTPServerTransport(
mcp_session_id=session_id,
is_json_response_enabled=True, # 启用 JSON 响应
)
# 在后台运行 MCP 服务器
async def run_mcp_server():
async with transport.connect() as streams:
await self.server.run(
streams[0],
streams[1],
self.server.create_initialization_options(),
)
# 启动 MCP 服务器任务
asyncio.create_task(run_mcp_server())
# 创建 Starlette 应用transport.handle_request 是 ASGI 应用)
app = Starlette()
app.mount("/", transport.handle_request)
# 获取端口配置
port = int(os.getenv("FUNSTAT_PORT", "8091"))
host = os.getenv("FUNSTAT_HOST", "127.0.0.1")
logger.info(f"🌐 启动 SSE 服务器: http://{host}:{port}")
logger.info(f"📡 SSE 端点: http://{host}:{port}/sse")
logger.info(f"📨 消息端点: http://{host}:{port}/messages")
if session_id:
logger.info(f"🔒 Session ID: {session_id}")
# 启动服务器
config = uvicorn.Config(
app,
host=host,
port=port,
log_level="info"
)
server_instance = uvicorn.Server(config)
await server_instance.serve()
async def main():
"""主函数"""
server = FunstatMCPServer()
await server.run()
if __name__ == "__main__":
asyncio.run(main())