Add deployment scripts and documentation
- Add deployment scripts (deploy.sh, test_connection.sh, core/start_server.sh) - Add deployment documentation (DEPLOYMENT_INFO.md, DEPLOYMENT_SUCCESS.md) - Add .env.example configuration template - Add requirements.txt for Python dependencies - Update README.md with latest information - Update core/server.py with improvements 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
271
core/server.py
271
core/server.py
@@ -25,6 +25,8 @@ from mcp.types import (
|
||||
)
|
||||
from pydantic import AnyUrl
|
||||
from telethon import TelegramClient
|
||||
from telethon.errors import FloodWaitError
|
||||
from telethon.tl.functions.messages import GetBotCallbackAnswerRequest
|
||||
from telethon.tl.types import Message
|
||||
|
||||
# 配置日志
|
||||
@@ -56,6 +58,40 @@ RATE_LIMIT_WINDOW = 1.0 # 1秒时间窗口
|
||||
# 缓存配置
|
||||
CACHE_TTL = 3600 # 缓存1小时
|
||||
|
||||
# 按钮文本转换表,用于将常见的变体字符标准化为 ASCII
|
||||
BUTTON_TEXT_TRANSLATIONS = str.maketrans({
|
||||
'ƒ': 'f',
|
||||
'Μ': 'M',
|
||||
'τ': 't',
|
||||
'ѕ': 's',
|
||||
'η': 'n',
|
||||
'Ғ': 'F',
|
||||
'α': 'a',
|
||||
'ο': 'o',
|
||||
'ᴜ': 'u',
|
||||
'о': 'o',
|
||||
'е': 'e',
|
||||
'с': 'c',
|
||||
'℮': 'e',
|
||||
'Τ': 'T',
|
||||
'ρ': 'p',
|
||||
'Δ': 'D',
|
||||
'χ': 'x',
|
||||
'β': 'b',
|
||||
'λ': 'l',
|
||||
'γ': 'y',
|
||||
'Ν': 'N',
|
||||
'μ': 'm',
|
||||
'ψ': 'y',
|
||||
'Α': 'A',
|
||||
'Ρ': 'P',
|
||||
'С': 'C',
|
||||
'ё': 'e',
|
||||
'ł': 'l',
|
||||
'Ł': 'L',
|
||||
'ց': 'g',
|
||||
})
|
||||
|
||||
|
||||
class RateLimiter:
|
||||
"""速率限制器"""
|
||||
@@ -136,6 +172,123 @@ class FunstatMCPServer:
|
||||
self.server.list_tools()(self.list_tools)
|
||||
self.server.call_tool()(self.call_tool)
|
||||
|
||||
def _normalize_button_text(self, text: str) -> str:
|
||||
"""标准化按钮文本,消除不同字符集的影响"""
|
||||
return text.translate(BUTTON_TEXT_TRANSLATIONS)
|
||||
|
||||
async def _press_button(self, message: Message, keyword: str) -> Message:
|
||||
"""在消息中查找包含关键字的按钮并触发"""
|
||||
if not message.buttons:
|
||||
raise ValueError(f"消息中缺少可用按钮,无法执行 {keyword} 操作")
|
||||
|
||||
target_button = None
|
||||
normalized_keyword = keyword.lower()
|
||||
|
||||
for row in message.buttons:
|
||||
for button in row:
|
||||
normalized_text = self._normalize_button_text(button.text).lower()
|
||||
if normalized_keyword in normalized_text:
|
||||
target_button = button
|
||||
break
|
||||
if target_button:
|
||||
break
|
||||
|
||||
if not target_button:
|
||||
available = [
|
||||
self._normalize_button_text(button.text)
|
||||
for row in message.buttons
|
||||
for button in row
|
||||
]
|
||||
raise ValueError(
|
||||
f"未找到包含关键字 '{keyword}' 的按钮。可用按钮: {available}"
|
||||
)
|
||||
|
||||
await self.rate_limiter.acquire()
|
||||
|
||||
try:
|
||||
await self.client(
|
||||
GetBotCallbackAnswerRequest(
|
||||
peer=self.bot_entity,
|
||||
msg_id=message.id,
|
||||
data=target_button.data,
|
||||
)
|
||||
)
|
||||
except FloodWaitError as exc:
|
||||
wait_seconds = exc.seconds + 1
|
||||
logger.warning("触发 Telegram FloodWait,需要等待 %s 秒", wait_seconds)
|
||||
await asyncio.sleep(wait_seconds)
|
||||
await self.client(
|
||||
GetBotCallbackAnswerRequest(
|
||||
peer=self.bot_entity,
|
||||
msg_id=message.id,
|
||||
data=target_button.data,
|
||||
)
|
||||
)
|
||||
|
||||
await asyncio.sleep(1.2)
|
||||
refreshed = await self.client.get_messages(self.bot_entity, ids=message.id)
|
||||
if not refreshed:
|
||||
raise RuntimeError("回调执行后未能获取最新消息内容")
|
||||
return refreshed
|
||||
|
||||
def _extract_total_pages(self, message: Message) -> Optional[int]:
|
||||
"""从按钮中提取总页数(如果提供了跳页按钮)"""
|
||||
if not message.buttons:
|
||||
return None
|
||||
|
||||
total_pages = None
|
||||
for row in message.buttons:
|
||||
for button in row:
|
||||
if '⏭' in button.text:
|
||||
normalized = self._normalize_button_text(button.text)
|
||||
digits = ''.join(ch for ch in normalized if ch.isdigit())
|
||||
if digits:
|
||||
try:
|
||||
total_pages = int(digits)
|
||||
except ValueError:
|
||||
continue
|
||||
return total_pages
|
||||
|
||||
|
||||
async def send_command_and_wait_message(
|
||||
self,
|
||||
command: str,
|
||||
timeout: int = 10,
|
||||
) -> Message:
|
||||
"""发送命令并返回原始消息对象(包含按钮等信息)"""
|
||||
if not self.client or not self.bot_entity:
|
||||
raise RuntimeError("Telegram 客户端尚未初始化")
|
||||
|
||||
await self.rate_limiter.acquire()
|
||||
|
||||
last_message_id = 0
|
||||
async for message in self.client.iter_messages(self.bot_entity, limit=1):
|
||||
last_message_id = message.id
|
||||
break
|
||||
|
||||
logger.info("📤 发送命令(原始消息模式): %s", command)
|
||||
await self.client.send_message(self.bot_entity, command)
|
||||
|
||||
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:
|
||||
continue
|
||||
if message.out:
|
||||
continue
|
||||
if message.text or message.buttons:
|
||||
logger.info(
|
||||
"✅ 收到原始响应 (ID: %s, 文本长度: %s)",
|
||||
message.id,
|
||||
len(message.text or ""),
|
||||
)
|
||||
return message
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
raise TimeoutError(f"等待 BOT 响应超时 ({timeout}秒)")
|
||||
|
||||
async def initialize(self):
|
||||
"""初始化 Telegram 客户端"""
|
||||
logger.info("初始化 Telegram 客户端...")
|
||||
@@ -246,6 +399,99 @@ class FunstatMCPServer:
|
||||
|
||||
raise TimeoutError(f"等待 BOT 响应超时 ({timeout}秒)")
|
||||
|
||||
async def fetch_user_messages(
|
||||
self,
|
||||
identifier: str,
|
||||
max_pages: Optional[int] = None
|
||||
) -> str:
|
||||
"""获取指定用户的历史消息,支持自动翻页"""
|
||||
if not identifier or not identifier.strip():
|
||||
raise ValueError("用户标识不能为空")
|
||||
|
||||
identifier = identifier.strip()
|
||||
display_identifier = identifier
|
||||
|
||||
if identifier.startswith("/"):
|
||||
command = identifier
|
||||
else:
|
||||
if not identifier.startswith("@") and not identifier.replace("+", "").isdigit():
|
||||
identifier = f"@{identifier}"
|
||||
display_identifier = identifier
|
||||
command = f"/user_info {identifier}"
|
||||
|
||||
logger.info("开始获取用户消息: %s", display_identifier)
|
||||
|
||||
base_message = await self.send_command_and_wait_message(command, timeout=15)
|
||||
|
||||
message_stage = await self._press_button(base_message, "messages")
|
||||
all_stage = await self._press_button(message_stage, "all")
|
||||
|
||||
collected_pages: List[str] = []
|
||||
seen_texts: set[str] = set()
|
||||
current_message = all_stage
|
||||
current_page = 1
|
||||
total_pages = self._extract_total_pages(current_message)
|
||||
|
||||
if max_pages is not None and max_pages <= 0:
|
||||
raise ValueError("max_pages 必须大于 0")
|
||||
|
||||
while True:
|
||||
page_text = current_message.text or ""
|
||||
normalized_text = page_text.strip()
|
||||
|
||||
if normalized_text and normalized_text not in seen_texts:
|
||||
header_parts = [f"第 {current_page} 页"]
|
||||
if total_pages:
|
||||
header_parts[-1] += f"/{total_pages}"
|
||||
header_parts.append(f"用户: {display_identifier}")
|
||||
collected_pages.append(
|
||||
"\n".join(header_parts + ["", page_text.strip()])
|
||||
)
|
||||
seen_texts.add(normalized_text)
|
||||
|
||||
if max_pages and current_page >= max_pages:
|
||||
logger.info("达到 max_pages 限制,停止翻页")
|
||||
break
|
||||
|
||||
next_button = None
|
||||
if current_message.buttons:
|
||||
for row in current_message.buttons:
|
||||
for button in row:
|
||||
if "➡" in button.text:
|
||||
next_button = button
|
||||
break
|
||||
if next_button:
|
||||
break
|
||||
|
||||
if not next_button:
|
||||
break
|
||||
|
||||
logger.info("翻到第 %s 页 (目标按钮: %s)", current_page + 1, next_button.text)
|
||||
|
||||
try:
|
||||
current_message = await self._press_button(current_message, "➡")
|
||||
except ValueError:
|
||||
logger.warning("未能找到下一页按钮,提前结束翻页")
|
||||
break
|
||||
|
||||
# 如果返回的内容与上一页一致,则终止
|
||||
if (current_message.text or "").strip() in seen_texts:
|
||||
logger.info("检测到重复页面内容,结束翻页")
|
||||
break
|
||||
|
||||
current_page += 1
|
||||
|
||||
if not collected_pages:
|
||||
return f"未找到 {display_identifier} 的消息记录。"
|
||||
|
||||
summary_lines = [
|
||||
f"共收集 {len(collected_pages)} 页消息"
|
||||
+ (f"(存在 {total_pages} 页)" if total_pages else ""),
|
||||
""
|
||||
]
|
||||
|
||||
return "\n\n".join(summary_lines + collected_pages)
|
||||
|
||||
async def list_tools(self) -> List[Tool]:
|
||||
"""列出所有可用工具"""
|
||||
return [
|
||||
@@ -318,6 +564,25 @@ class FunstatMCPServer:
|
||||
"required": ["identifier"]
|
||||
}
|
||||
),
|
||||
Tool(
|
||||
name="funstat_user_messages",
|
||||
description="获取指定用户的历史消息列表,并自动翻页汇总",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"identifier": {
|
||||
"type": "string",
|
||||
"description": "用户标识: 用户名(@username) 或用户ID"
|
||||
},
|
||||
"max_pages": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"description": "可选,限制抓取的最大页数"
|
||||
}
|
||||
},
|
||||
"required": ["identifier"]
|
||||
}
|
||||
),
|
||||
Tool(
|
||||
name="funstat_balance",
|
||||
description="查询当前账号的积分余额和使用统计",
|
||||
@@ -381,6 +646,12 @@ class FunstatMCPServer:
|
||||
response = await self.send_command_and_wait(f"/user_info {identifier}")
|
||||
return [TextContent(type="text", text=response)]
|
||||
|
||||
elif name == "funstat_user_messages":
|
||||
identifier = arguments["identifier"]
|
||||
max_pages = arguments.get("max_pages")
|
||||
response = await self.fetch_user_messages(identifier, max_pages=max_pages)
|
||||
return [TextContent(type="text", text=response)]
|
||||
|
||||
elif name == "funstat_balance":
|
||||
response = await self.send_command_and_wait("/balance")
|
||||
return [TextContent(type="text", text=response)]
|
||||
|
||||
64
core/start_server.sh
Executable file
64
core/start_server.sh
Executable file
@@ -0,0 +1,64 @@
|
||||
#!/bin/bash
|
||||
# Funstat MCP 服务器启动脚本(适配服务器环境)
|
||||
|
||||
set -e
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
# 停止旧实例
|
||||
echo "🛑 停止旧服务器..."
|
||||
pkill -f "funstat.*server.py" 2>/dev/null || true
|
||||
sleep 2
|
||||
|
||||
# 确保 session 文件没有被锁定
|
||||
SESSION_FILE=~/telegram_sessions/funstat_bot.session
|
||||
if [ -f "$SESSION_FILE" ]; then
|
||||
if lsof "$SESSION_FILE" 2>/dev/null; then
|
||||
echo "⚠️ Session 文件被占用,强制终止..."
|
||||
pkill -9 -f "funstat.*server.py" || true
|
||||
sleep 2
|
||||
fi
|
||||
else
|
||||
echo "❌ Session 文件不存在: $SESSION_FILE"
|
||||
echo "请先上传 session 文件!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 激活虚拟环境
|
||||
source ../.venv/bin/activate
|
||||
|
||||
# 启动新服务器(后台运行)
|
||||
echo "🚀 启动新服务器..."
|
||||
nohup python3 server.py > /tmp/funstat_sse.log 2>&1 &
|
||||
SERVER_PID=$!
|
||||
|
||||
# 等待启动
|
||||
sleep 3
|
||||
|
||||
# 验证启动
|
||||
if ps -p $SERVER_PID > /dev/null 2>&1; then
|
||||
echo "✅ 服务器已启动 (PID: $SERVER_PID)"
|
||||
echo "📡 SSE 端点: http://127.0.0.1:8091/sse"
|
||||
echo "📋 日志文件: /tmp/funstat_sse.log"
|
||||
|
||||
# 测试端点
|
||||
echo ""
|
||||
echo "🧪 测试端点..."
|
||||
if curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:8091/sse | grep -q "200"; then
|
||||
echo "✅ GET /sse 测试通过"
|
||||
else
|
||||
echo "❌ GET /sse 测试失败"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "📊 服务器状态:"
|
||||
echo " 进程ID: $SERVER_PID"
|
||||
echo " 监听地址: http://127.0.0.1:8091"
|
||||
echo " 日志: tail -f /tmp/funstat_sse.log"
|
||||
echo ""
|
||||
echo "停止服务: pkill -f 'funstat.*server.py'"
|
||||
else
|
||||
echo "❌ 服务器启动失败!"
|
||||
echo "查看日志: tail -50 /tmp/funstat_sse.log"
|
||||
exit 1
|
||||
fi
|
||||
Reference in New Issue
Block a user