diff --git a/.env.example b/.env.example index 0599a2b..28c8b8b 100644 --- a/.env.example +++ b/.env.example @@ -1,15 +1,42 @@ -# Telegram API 配置 -TELEGRAM_API_ID=your_api_id -TELEGRAM_API_HASH=your_api_hash +# ──────────────────────────────────────────────────────────────── +# Telegram 配置 +# ──────────────────────────────────────────────────────────────── +TELEGRAM_API_ID=24660516 +TELEGRAM_API_HASH=eae564578880a59c9963916ff1bbbd3a +# 每位同事需要使用自己的 session 文件,路径可自定义 TELEGRAM_SESSION_PATH=~/telegram_sessions/funstat_bot -FUNSTAT_BOT_USERNAME=@openaiw_bot +# 机器人账号信息(token 稍后替换为最新值) +TELEGRAM_BOT_USERNAME=@ktqiangda_bot +TELEGRAM_BOT_TOKEN=7321478881:AAFVmSXsfAbXI2Sfx9Sg3UW5ufAKvPsbO4U -# 代理配置(如果服务器无法直接访问 Telegram,需要配置代理) -FUNSTAT_PROXY_TYPE=socks5 -FUNSTAT_PROXY_HOST=127.0.0.1 -FUNSTAT_PROXY_PORT=1080 +# ──────────────────────────────────────────────────────────────── +# MCP 服务器配置 +# ──────────────────────────────────────────────────────────────── +FUNSTAT_HOST=127.0.0.1 +FUNSTAT_PORT=8094 +FUNSTAT_ENABLE_PAGINATION=true +FUNSTAT_PAGINATION_MAX_PAGES=10 +FUNSTAT_PAGINATION_DELAY=2.0 +FUNSTAT_PAGINATION_TIMEOUT=8.0 +FUNSTAT_PAGINATION_KEYWORDS=➡️,下一页,Next,更多,下页,›,>> -# 服务器配置 -# 监听地址: 0.0.0.0 表示所有网络接口,127.0.0.1 表示仅本地访问 -FUNSTAT_HOST=0.0.0.0 -FUNSTAT_PORT=8091 +# ──────────────────────────────────────────────────────────────── +# Postgres / Docker 容器配置 +# ──────────────────────────────────────────────────────────────── +POSTGRES_DB=funstat +POSTGRES_USER=funstat +POSTGRES_PASSWORD=funstat_dev_password +POSTGRES_PORT=5433 +# Python 侧读取数据库的 URL +DATABASE_URL=postgresql://funstat:funstat_dev_password@127.0.0.1:5433/funstat + +# ──────────────────────────────────────────────────────────────── +# 远端数据沉淀(SSH 上传) +# ──────────────────────────────────────────────────────────────── +REMOTE_UPLOAD_ENABLED=true +REMOTE_SSH_HOST=172.16.74.159 +REMOTE_SSH_USER=atai +REMOTE_SSH_PASSWORD=wengewudi666808 +REMOTE_SSH_TARGET=/home/atai/funstat_data/inbox +REMOTE_UPLOAD_INTERVAL=120 +REMOTE_UPLOAD_BATCH_SIZE=200 diff --git a/.gitignore b/.gitignore index 26e6ccc..a154df8 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ logs/ *.log *.sqlite *.session +local_data/postgres/ diff --git a/DEPLOYMENT_INFO.md b/DEPLOYMENT_INFO.md deleted file mode 100644 index 73baba9..0000000 --- a/DEPLOYMENT_INFO.md +++ /dev/null @@ -1,177 +0,0 @@ -# Funstat MCP 部署信息 - -## 服务器信息 - -- **服务器 IP**: 172.16.74.159 -- **用户**: atai -- **部署路径**: /home/atai/funstat-mcp -- **端口**: 8091 (本地监听) - -## 部署状态 - -✅ **部署成功** - 2025-11-02 - -### 已安装组件 - -- Python 3.12.3 -- 虚拟环境: /home/atai/funstat-mcp/.venv -- MCP 服务器: v1.20.0 -- Telethon: v1.41.2 -- 代理: v2ray (SOCKS5 on 127.0.0.1:1080) - -### 配置文件 - -- **环境变量**: /home/atai/funstat-mcp/.env -- **Session 文件**: ~/telegram_sessions/funstat_bot.session -- **启动脚本**: /home/atai/funstat-mcp/core/start_server_with_env.sh - -## 服务管理 - -### 启动服务 - -```bash -ssh atai@172.16.74.159 -cd /home/atai/funstat-mcp/core -bash start_server_with_env.sh -``` - -### 停止服务 - -```bash -ssh atai@172.16.74.159 -pkill -f 'funstat.*server.py' -``` - -### 查看日志 - -```bash -ssh atai@172.16.74.159 -tail -f /tmp/funstat_sse.log -``` - -### 检查状态 - -```bash -ssh atai@172.16.74.159 -ps aux | grep python | grep server.py -netstat -tuln | grep 8091 # 或 ss -tuln | grep 8091 -``` - -## 服务端点 - -- **SSE 端点**: http://172.16.74.159:8091/sse -- **消息端点**: http://172.16.74.159:8091/messages -- **内部访问**: http://127.0.0.1:8091/sse - -✅ 服务监听 0.0.0.0:8091,可从任何网络接口访问。 - -## 环境变量 - -当前配置在 `/home/atai/funstat-mcp/.env`: - -```bash -# Telegram API 配置 -TELEGRAM_API_ID=24660516 -TELEGRAM_API_HASH=eae564578880a59c9963916ff1bbbd3a -TELEGRAM_SESSION_PATH=~/telegram_sessions/funstat_bot -FUNSTAT_BOT_USERNAME=@openaiw_bot - -# 代理配置 -FUNSTAT_PROXY_TYPE=socks5 -FUNSTAT_PROXY_HOST=127.0.0.1 -FUNSTAT_PROXY_PORT=1080 - -# 服务器配置 -FUNSTAT_HOST=0.0.0.0 -FUNSTAT_PORT=8091 -``` - -## 重新部署 - -从本地更新代码到服务器: - -```bash -cd /Users/hahaha/projects/funstat-mcp -bash deploy.sh -``` - -部署脚本会自动: -1. 打包项目文件 -2. 上传到服务器 -3. 停止旧服务 -4. 解压更新文件 -5. 安装依赖 - -部署后需要手动启动服务: - -```bash -ssh atai@172.16.74.159 -cd /home/atai/funstat-mcp/core -bash start_server_with_env.sh -``` - -## 故障排除 - -### 1. 服务无法启动 - -检查日志: -```bash -tail -50 /tmp/funstat_sse.log -``` - -### 2. 无法连接 Telegram - -检查代理是否运行: -```bash -ps aux | grep v2ray -netstat -tuln | grep 1080 -``` - -### 3. Session 文件锁定 - -强制停止并重启: -```bash -pkill -9 -f 'funstat.*server.py' -sleep 2 -cd /home/atai/funstat-mcp/core -bash start_server_with_env.sh -``` - -## 运行日志示例 - -成功启动的日志应该包含: - -``` -✅ 已连接到: KT超级数据 -✅ 当前账号: @xiaobai_80 -🚀 Funstat MCP Server 已启动 -🌐 启动 SSE 服务器: http://127.0.0.1:8091 -Uvicorn running on http://127.0.0.1:8091 -``` - -## 安全注意事项 - -1. ⚠️ .env 文件包含敏感信息,已设置权限保护 -2. ⚠️ Session 文件包含登录凭证,定期备份 -3. ⚠️ 服务监听 0.0.0.0:8091,可从任何网络访问,请注意安全 -4. ⚠️ 服务器需要使用 v2ray 代理访问 Telegram(端口 1080),确保代理服务稳定 -5. ⚠️ 必须使用 start_server_with_env.sh 启动脚本,才能正确加载环境变量和代理配置 -6. ⚠️ 建议在生产环境配置防火墙规则,限制访问来源IP - -## 更新历史 - -- **2025-11-02**: 初次部署成功 - - 安装所有依赖(包括 PySocks) - - 配置 v2ray 代理(socks5://127.0.0.1:1080) - - 创建带环境变量的启动脚本 - - 配置服务监听 0.0.0.0:8091,支持外部访问 - - 服务正常运行 - - ⚠️ 注意:服务器无法直接访问 Telegram,必须使用代理 - - ✅ 可从任何网络通过 http://172.16.74.159:8091 访问 - -## 联系信息 - -- 服务器: 172.16.74.159:22 -- 账号: atai -- Bot: @openaiw_bot -- 当前 Telegram 账号: @xiaobai_80 diff --git a/DEPLOYMENT_SUCCESS.md b/DEPLOYMENT_SUCCESS.md deleted file mode 100644 index ffb97e6..0000000 --- a/DEPLOYMENT_SUCCESS.md +++ /dev/null @@ -1,252 +0,0 @@ -# Funstat MCP 部署成功报告 - -## ✅ 部署状态:成功 - -**部署时间**: 2025-11-02 -**服务器**: 172.16.74.159 (atai) -**服务进程**: PID 105552 - ---- - -## 🌐 访问信息 - -### 外部访问 -- **SSE 端点**: http://172.16.74.159:8091/sse -- **消息端点**: http://172.16.74.159:8091/messages - -### 内部访问(服务器内) -- http://127.0.0.1:8091/sse -- http://127.0.0.1:8091/messages - -### 监听配置 -- **监听地址**: 0.0.0.0 (所有网络接口) -- **监听端口**: 8091 -- **协议**: HTTP + SSE - ---- - -## ✅ 服务状态 - -```bash -# 进程状态 -PID: 105552 -运行中: ✅ -内存使用: 78304 KB - -# 网络状态 -端口: 0.0.0.0:8091 LISTEN -防火墙: inactive (无限制) - -# Telegram 连接 -状态: ✅ 已连接 -Bot: @openaiw_bot (KT超级数据) -账号: @xiaobai_80 (ID: 7363537082) -代理: socks5://127.0.0.1:1080 -``` - ---- - -## 📋 部署清单 - -### 1. 系统环境 -- [x] Ubuntu 24.04.3 LTS -- [x] Python 3.12.3 -- [x] v2ray 代理运行中 (端口 1080) - -### 2. 项目部署 -- [x] 代码部署到 /home/atai/funstat-mcp -- [x] 虚拟环境创建 (.venv) -- [x] 依赖包安装完成 (mcp, telethon, starlette, uvicorn, PySocks, etc.) - -### 3. 配置文件 -- [x] .env 环境变量配置 -- [x] Session 文件 (~/telegram_sessions/funstat_bot.session) -- [x] 启动脚本 (start_server_with_env.sh) - -### 4. 服务配置 -- [x] 监听地址: 0.0.0.0:8091 -- [x] 代理配置: socks5://127.0.0.1:1080 -- [x] 自动重启脚本 - ---- - -## 🔧 管理命令 - -### 启动服务 -```bash -ssh atai@172.16.74.159 -cd /home/atai/funstat-mcp/core -bash start_server_with_env.sh -``` - -### 停止服务 -```bash -ssh atai@172.16.74.159 -pkill -f 'funstat.*server.py' -``` - -### 查看日志 -```bash -ssh atai@172.16.74.159 -tail -f /tmp/funstat_sse.log -``` - -### 检查状态 -```bash -ssh atai@172.16.74.159 -ps aux | grep 'python.*server.py' | grep -v grep -ss -tuln | grep 8091 -``` - -### 测试访问 -```bash -# 从服务器内部测试 -ssh atai@172.16.74.159 -curl -s -o /dev/null -w '%{http_code}\n' http://172.16.74.159:8091/sse - -# 从本地测试(如果网络可达) -curl -s -o /dev/null -w '%{http_code}\n' http://172.16.74.159:8091/sse -``` - ---- - -## 🔄 重新部署 - -### 方法 1: 使用部署脚本(本地执行) -```bash -cd /Users/hahaha/projects/funstat-mcp -bash deploy.sh -``` - -然后登录服务器启动: -```bash -ssh atai@172.16.74.159 -cd /home/atai/funstat-mcp/core -bash start_server_with_env.sh -``` - -### 方法 2: 手动部署 -```bash -# 1. 打包代码 -cd /Users/hahaha/projects/funstat-mcp -tar --exclude='.git' --exclude='.venv' -czf /tmp/funstat-mcp.tar.gz . - -# 2. 上传到服务器 -scp /tmp/funstat-mcp.tar.gz atai@172.16.74.159:/tmp/ - -# 3. 在服务器上解压并重启 -ssh atai@172.16.74.159 -cd /home/atai/funstat-mcp -tar -xzf /tmp/funstat-mcp.tar.gz -cd core -bash start_server_with_env.sh -``` - ---- - -## ⚠️ 重要说明 - -### 网络要求 -- ⚠️ **服务器无法直接访问 Telegram**,必须通过 v2ray 代理(端口 1080) -- ✅ v2ray 代理已配置并运行正常 -- ✅ 服务已配置使用 socks5://127.0.0.1:1080 代理 - -### 代理依赖 -如果代理服务停止,Telegram 连接将失败。检查代理状态: -```bash -ssh atai@172.16.74.159 -ps aux | grep v2ray -ss -tuln | grep 1080 -``` - -### Session 文件 -- 路径: ~/telegram_sessions/funstat_bot.session -- 大小: 72KB -- 权限: 600 (仅所有者可读写) -- ⚠️ 包含登录凭证,务必保管好 - -### 防火墙 -- 当前状态: inactive (无防火墙限制) -- 端口 8091 对所有网络开放 -- 如需限制访问,可配置 ufw 规则 - ---- - -## 📊 访问日志示例 - -服务器日志显示已有外部访问: -``` -INFO: 172.16.72.87:61462 - "GET /sse HTTP/1.1" 406 Not Acceptable -INFO: 172.16.74.159:58984 - "GET /sse HTTP/1.1" 406 Not Acceptable -INFO: 172.16.72.87:61532 - "GET /sse HTTP/1.1" 200 OK -``` - -- 406 响应: 正常,表示客户端未发送正确的 Accept 头 -- 200 响应: 成功建立 SSE 连接 - ---- - -## 🛡️ 安全建议 - -1. ✅ Session 文件已设置权限保护 (600) -2. ✅ .env 文件包含敏感信息,不要提交到 Git -3. ⚠️ 服务监听 0.0.0.0,建议配置防火墙限制访问 IP -4. ⚠️ 定期备份 session 文件 -5. ⚠️ 确保 v2ray 代理服务稳定运行 - -### 推荐的防火墙配置(可选) -```bash -sudo ufw allow from /32 to any port 8091 -sudo ufw enable -``` - ---- - -## 📞 技术支持 - -如遇问题,按以下顺序排查: - -1. **服务未启动** - ```bash - cd /home/atai/funstat-mcp/core - bash start_server_with_env.sh - ``` - -2. **无法连接 Telegram** - - 检查 v2ray 代理: `ps aux | grep v2ray` - - 检查代理端口: `ss -tuln | grep 1080` - - 查看服务日志: `tail -50 /tmp/funstat_sse.log` - -3. **Session 文件锁定** - ```bash - pkill -9 python3 - sleep 2 - cd /home/atai/funstat-mcp/core - bash start_server_with_env.sh - ``` - -4. **依赖包缺失** - ```bash - cd /home/atai/funstat-mcp - source .venv/bin/activate - pip install -r requirements.txt - ``` - ---- - -## ✅ 验证清单 - -部署完成后,确认以下项目: - -- [x] 服务进程正在运行 -- [x] 端口 8091 正在监听 -- [x] Telegram 连接成功 -- [x] SSE 端点响应正常 -- [x] 日志文件正常记录 -- [x] 可从外部访问(如果网络可达) - ---- - -**部署完成时间**: 2025-11-02 -**状态**: ✅ 成功运行 -**下次维护**: 定期检查日志和代理状态 diff --git a/README.md b/README.md index 6ae09eb..97d28cd 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,25 @@ Funstat MCP 提供一个面向多客户端的统一接口,将 Telegram 上的 --- +> ⚠️ **端口更新**:当前本地部署已改为监听 `http://127.0.0.1:8094`(原默认 8091)。请在 Codex、Cursor、Claude Code 等客户端更新为 `http://127.0.0.1:8094/sse`。 + +## ⚙️ 新版数据流概览 + +- **PostgreSQL 本地存储**:所有 MCP 查询结果会自动写入本地 Postgres(默认通过 `docker compose up -d postgres` 启动,端口 `5433`)。数据包含原始返回、解析后的实体,以及同步状态。 +- **自动远端沉淀**:后台会周期性将去重后的最新数据打包为 JSON,并通过 `sshpass` 上传到 `172.16.74.159` 的 `~/funstat_data/inbox` 目录,方便集中备份(暂不直接对外提供查询)。 +- **配置模块化**:`core/config.py` + `.env` 控制 Telegram API、Bot Token、数据库与同步参数。每位同事只需复制 `.env.example`,填写自己的 session / token 即可。 +- **Bot 可热切换**:所有脚本与服务器都不再写死 token,替换 `.env` 中的 `TELEGRAM_BOT_TOKEN` 即可切换镜像机器人。 +- **默认镜像机器人**:发行包已预置最新官方镜像 `@ktqiangda_bot`(Token:`7321478881:AAFVmSXsfAbXI2Sfx9Sg3UW5ufAKvPsbO4U`)。如后续提供新的镜像,只需改 `.env` 即可。 + +### 快速启动顺序 +1. `cp .env.example .env` 并根据实际环境填写(尤其是 Telegram API、Bot Token、session 路径)。 +2. 启动本地 Postgres:`docker compose up -d postgres`(如本机已有 5433 占用,可在 `.env` 里改为其他端口)。 +3. 安装依赖:`pip3 install -r requirements.txt --user --break-system-packages`。 +4. 使用自己的 Telegram 账号创建 session:`python3 scripts/create_session_safe.py`。 +5. 运行服务器:`cd core && python3 server.py` 或 `./start_sse_prod.sh`。 + +只要 `.env` 中的远端 SSH 信息保持有效,所有数据会自动异步推送到服务器侧的归档目录,无需额外手动操作。 + ## 📦 包内容概览 这个打包包含了完整的 Funstat MCP 服务器实现,以及所有相关文档、配置和工具。 @@ -49,7 +68,7 @@ funstat_mcp_package/ ## 🎯 核心功能 -### MCP 工具列表 (9个) +### MCP 工具列表 (8个) | 工具名 | 功能 | 对应命令 | |--------|------|---------| @@ -61,7 +80,6 @@ funstat_mcp_package/ | `funstat_text` | 搜索消息内容 | `/text [关键词]` | | `funstat_human` | 搜索用户 | `/human [关键词]` | | `funstat_user_info` | 查询用户详情 | `/user_info [username]` | -| `funstat_user_messages` | 获取用户聊天记录(自动翻页) | `/user_info [username] ➜ Messages ➜ All` | ### 协议支持 diff --git a/config_examples/claude-code-mcp-config.json b/config_examples/claude-code-mcp-config.json index c9acdd5..ea88da8 100644 --- a/config_examples/claude-code-mcp-config.json +++ b/config_examples/claude-code-mcp-config.json @@ -1,7 +1,7 @@ { "funstat": { "command": "/Users/lucas/牛马/agentapi", - "args": ["proxy", "http://127.0.0.1:8091/sse"], + "args": ["proxy", "http://127.0.0.1:8094/sse"], "env": {} } } diff --git a/config_examples/cursor-mcp.json b/config_examples/cursor-mcp.json index a9d2134..23c9914 100644 --- a/config_examples/cursor-mcp.json +++ b/config_examples/cursor-mcp.json @@ -2,7 +2,7 @@ "mcpServers": { "funstat": { "command": "/Users/lucas/牛马/agentapi", - "args": ["proxy", "http://127.0.0.1:8091/sse"], + "args": ["proxy", "http://127.0.0.1:8094/sse"], "env": {} } } diff --git a/core/config.py b/core/config.py new file mode 100644 index 0000000..23a30b3 --- /dev/null +++ b/core/config.py @@ -0,0 +1,103 @@ +import os +from dataclasses import dataclass +from functools import lru_cache +from pathlib import Path +from typing import Optional + +from dotenv import load_dotenv + + +BASE_DIR = Path(__file__).resolve().parents[1] +DOTENV_PATH = BASE_DIR / ".env" + +if DOTENV_PATH.exists(): + load_dotenv(DOTENV_PATH) + + +@dataclass +class Settings: + telegram_api_id: int + telegram_api_hash: str + telegram_session_path: str + telegram_bot_username: str + telegram_bot_token: Optional[str] + + host: str + port: int + + enable_pagination: bool + pagination_max_pages: int + pagination_delay: float + pagination_timeout: float + pagination_keywords: list[str] + + database_url: str + + remote_upload_enabled: bool + remote_ssh_host: str + remote_ssh_user: str + remote_ssh_password: str + remote_ssh_target: str + remote_upload_interval: int + remote_upload_batch_size: int + + def __post_init__(self): + if not self.database_url: + raise ValueError("DATABASE_URL 未配置,无法初始化数据库连接") + + +def _env_bool(name: str, default: bool = False) -> bool: + return os.getenv(name, str(default)).strip().lower() in ("1", "true", "yes", "on") + + +def _env_int(name: str, default: int) -> int: + try: + return int(os.getenv(name, str(default))) + except ValueError: + return default + + +def _env_float(name: str, default: float) -> float: + try: + return float(os.getenv(name, str(default))) + except ValueError: + return default + + +@lru_cache() +def get_settings() -> Settings: + keywords = os.getenv( + "FUNSTAT_PAGINATION_KEYWORDS", + "➡️,下一页,Next,更多,下页,›,>>" + ) + + return Settings( + telegram_api_id=_env_int("TELEGRAM_API_ID", 24660516), + telegram_api_hash=os.getenv("TELEGRAM_API_HASH", "eae564578880a59c9963916ff1bbbd3a"), + telegram_session_path=os.getenv( + "TELEGRAM_SESSION_PATH", + str(Path.home() / "telegram_sessions" / "funstat_bot") + ), + telegram_bot_username=os.getenv("TELEGRAM_BOT_USERNAME", "@ktqiangda_bot"), + telegram_bot_token=os.getenv("TELEGRAM_BOT_TOKEN"), + host=os.getenv("FUNSTAT_HOST", "127.0.0.1"), + port=_env_int("FUNSTAT_PORT", 8094), + enable_pagination=_env_bool("FUNSTAT_ENABLE_PAGINATION", True), + pagination_max_pages=_env_int("FUNSTAT_PAGINATION_MAX_PAGES", 10), + pagination_delay=_env_float("FUNSTAT_PAGINATION_DELAY", 2.0), + pagination_timeout=_env_float("FUNSTAT_PAGINATION_TIMEOUT", 8.0), + pagination_keywords=[ + kw.strip() for kw in keywords.split(",") if kw.strip() + ], + database_url=os.getenv( + "DATABASE_URL", + "postgresql://funstat:funstat_dev_password@127.0.0.1:5433/funstat" + ), + remote_upload_enabled=_env_bool("REMOTE_UPLOAD_ENABLED", True), + remote_ssh_host=os.getenv("REMOTE_SSH_HOST", ""), + remote_ssh_user=os.getenv("REMOTE_SSH_USER", ""), + remote_ssh_password=os.getenv("REMOTE_SSH_PASSWORD", ""), + remote_ssh_target=os.getenv("REMOTE_SSH_TARGET", "/home/atai/funstat_data/inbox"), + remote_upload_interval=_env_int("REMOTE_UPLOAD_INTERVAL", 120), + remote_upload_batch_size=_env_int("REMOTE_UPLOAD_BATCH_SIZE", 200), + ) diff --git a/core/models.py b/core/models.py new file mode 100644 index 0000000..4c8d3f7 --- /dev/null +++ b/core/models.py @@ -0,0 +1,18 @@ +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional + + +Entity = Dict[str, Any] + + +@dataclass +class PageRecord: + page_number: int + text: str + entities: List[Entity] = field(default_factory=list) + + +@dataclass +class BotResponse: + text: str + pages: Optional[List[PageRecord]] = None diff --git a/core/parsers.py b/core/parsers.py new file mode 100644 index 0000000..9642bb5 --- /dev/null +++ b/core/parsers.py @@ -0,0 +1,50 @@ +import re +from typing import Any, Dict, List + + +USERNAME_PATTERN = re.compile(r"@([A-Za-z0-9_]{3,})") +TME_PATTERN = re.compile(r"(?:https?://)?t\.me/([A-Za-z0-9_]{3,})") +ID_PATTERN = re.compile(r"`(\d{4,})`") + + +def extract_entities(text: str) -> List[Dict[str, Any]]: + if not text: + return [] + + entities: List[Dict[str, Any]] = [] + seen = set() + + for user_id in set(ID_PATTERN.findall(text)): + key = ("user_id", user_id) + if key not in seen: + seen.add(key) + entities.append({ + "type": "user_id", + "value": user_id + }) + + for username in set(USERNAME_PATTERN.findall(text)): + normalized = username.strip() + if not normalized: + continue + key = ("username", normalized.lower()) + if key not in seen: + seen.add(key) + entities.append({ + "type": "username", + "value": normalized.lower(), + "display": normalized + }) + + for link in set(TME_PATTERN.findall(text)): + normalized = f"t.me/{link}" + key = ("tme_link", normalized.lower()) + if key not in seen: + seen.add(key) + entities.append({ + "type": "tme_link", + "value": normalized.lower(), + "display": normalized + }) + + return entities diff --git a/core/server.py b/core/server.py index a62b056..fcbefce 100644 --- a/core/server.py +++ b/core/server.py @@ -1,152 +1,88 @@ #!/usr/bin/env python3 """ -Funstat BOT MCP Server - -基于 Telethon 的 MCP 服务器,用于与 @openaiw_bot 交互 -提供搜索、查询、统计等功能 +Funstat BOT MCP Server(PostgreSQL + 远程同步版) """ 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 typing import Any, Dict, List, Optional from mcp.server import Server -from mcp.types import ( - Resource, - Tool, - TextContent, - ImageContent, - EmbeddedResource, -) -from pydantic import AnyUrl +from mcp.types import Tool, TextContent from telethon import TelegramClient -from telethon.errors import FloodWaitError -from telethon.tl.functions.messages import GetBotCallbackAnswerRequest from telethon.tl.types import Message -# 配置日志 +from config import get_settings +from parsers import extract_entities +from storage import StorageManager +from models import BotResponse, PageRecord +from uploader import RemoteUploader + + logging.basicConfig( level=logging.INFO, - format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' + 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") +settings = get_settings() +# 速率限制配置 +RATE_LIMIT_PER_SECOND = 18 +RATE_LIMIT_WINDOW = 1.0 +CACHE_TTL = 3600 + +# 代理配置(可选) 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小时 - -# 按钮文本转换表,用于将常见的变体字符标准化为 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: - """速率限制器""" - 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} 秒") + logger.info("速率限制: 等待 %.2f 秒", sleep_time) await asyncio.sleep(sleep_time) - return await self.acquire() # 递归重试 + 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}") + logger.info("缓存命中: %s", key) return value - else: - # 过期,删除 - del self.cache[key] + del self.cache[key] return None def set(self, key: str, value: Any): - """设置缓存""" self.cache[key] = (value, time.time()) - logger.info(f"缓存保存: {key}") + logger.info("缓存保存: %s", key) def clear_expired(self): - """清理过期缓存""" now = time.time() expired_keys = [ key for key, (_, timestamp) in self.cache.items() @@ -155,159 +91,40 @@ class ResponseCache: for key in expired_keys: del self.cache[key] if expired_keys: - logger.info(f"清理了 {len(expired_keys)} 个过期缓存") + logger.info("清理了 %s 个过期缓存", 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.account_display: Optional[str] = None self.rate_limiter = RateLimiter(RATE_LIMIT_PER_SECOND, RATE_LIMIT_WINDOW) self.cache = ResponseCache() + self.storage = StorageManager(settings.database_url) + self.uploader = RemoteUploader(self.storage, settings) if settings.remote_upload_enabled else None - # 注册处理器 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 客户端...") - - # 检查 session 文件 - session_file = f"{SESSION_PATH}.session" + session_base = os.path.expanduser(settings.telegram_session_path) + session_file = f"{session_base}.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/" + "请先运行 create_session_safe.py 使用自己的 Telegram 账号创建 session 文件" ) - logger.info(f"使用 Session 文件: {session_file}") - proxy = None if PROXY_HOST and PROXY_PORT: try: proxy_port = int(PROXY_PORT) + except ValueError: + logger.warning("代理端口无效,忽略代理配置: %s", PROXY_PORT) + else: if PROXY_USERNAME: proxy = ( PROXY_TYPE, @@ -318,192 +135,201 @@ class FunstatMCPServer: ) 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, - ) + logger.info("使用代理连接: %s://%s:%s", PROXY_TYPE, PROXY_HOST, proxy_port) - # 创建客户端 - self.client = TelegramClient(SESSION_PATH, API_ID, API_HASH, proxy=proxy) + self.client = TelegramClient( + session_base, + settings.telegram_api_id, + settings.telegram_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}") + logger.info("连接到 %s ...", settings.telegram_bot_username) + self.bot_entity = await self.client.get_entity(settings.telegram_bot_username) + logger.info("✅ 已连接到: %s", getattr(self.bot_entity, "first_name", settings.telegram_bot_username)) - # 获取当前用户信息 me = await self.client.get_me() - logger.info(f"✅ 当前账号: @{me.username} (ID: {me.id})") + self.account_display = f"@{me.username}" if me.username else f"ID:{me.id}" + logger.info("✅ 当前账号: %s (ID: %s)", self.account_display, me.id) async def send_command_and_wait( self, command: str, - timeout: int = 10, - use_cache: bool = True - ) -> str: - """发送命令到 BOT 并等待响应""" + timeout: int = 12, + use_cache: bool = True, + paginate: bool = False, + max_pages: Optional[int] = None + ) -> BotResponse: + paginate = paginate and settings.enable_pagination + effective_max_pages = max(1, settings.pagination_max_pages) + if max_pages: + effective_max_pages = min(effective_max_pages, max_pages) - # 检查缓存 - cache_key = f"cmd:{command}" + cache_key = f"cmd:{command}:paginate={int(paginate)}:max={effective_max_pages}" if use_cache: cached = self.cache.get(cache_key) if cached: return cached - # 速率限制 + if not self.client: + raise RuntimeError("Telegram 客户端尚未初始化") + await self.rate_limiter.acquire() + logger.info("📤 发送命令: %s", command) - 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() + last_message_id = await self._get_latest_message_id() await self.client.send_message(self.bot_entity, command) - - # 等待响应(稍等一下让 BOT 有时间响应) await asyncio.sleep(1.5) - # 获取新消息 start_time = time.time() + last_seen_text = None 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: + if message.out or not message.text: + continue + + is_new = message.id > last_message_id + is_updated = message.id == last_message_id and message.text != last_seen_text + + if is_new or is_updated: + last_seen_text = message.text + logger.info("✅ 收到响应 (%s 字符)", len(message.text)) + + if paginate and message.reply_markup: + pages = await self._collect_paginated_pages(message, effective_max_pages) + response_text = self._format_pages(pages) + else: + pages = [ + PageRecord( + page_number=1, + text=message.text, + entities=extract_entities(message.text) + ) + ] response_text = message.text - logger.info(f"✅ 收到响应 ({len(response_text)} 字符)") - # 保存到缓存 - if use_cache: - self.cache.set(cache_key, response_text) + result = BotResponse(text=response_text, pages=pages) + if use_cache: + self.cache.set(cache_key, result) + return result - return response_text - - # 继续等待 await asyncio.sleep(0.5) 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("用户标识不能为空") + async def _get_latest_message_id(self) -> int: + assert self.client is not None + async for message in self.client.iter_messages(self.bot_entity, limit=1): + return message.id + return 0 - 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 ""), - "" + async def _collect_paginated_pages(self, initial_message: Message, max_pages: int) -> List[PageRecord]: + pages: List[PageRecord] = [ + PageRecord( + page_number=1, + text=initial_message.text or "", + entities=extract_entities(initial_message.text or "") + ) ] + current_message = initial_message + current_text = initial_message.text or "" - return "\n\n".join(summary_lines + collected_pages) + for next_page in range(2, max_pages + 1): + button_info = self._find_next_page_button(current_message) + if not button_info: + break + + logger.info("➡️ 点击翻页按钮: %s", button_info["text"]) + await self.rate_limiter.acquire() + try: + await current_message.click(button_info["index"]) + except Exception as exc: + logger.warning("翻页按钮点击失败: %s", exc) + break + + await asyncio.sleep(settings.pagination_delay) + new_message = await self._wait_for_updated_message(current_text) + if not new_message or not new_message.text: + break + + current_message = new_message + current_text = new_message.text + pages.append( + PageRecord( + page_number=next_page, + text=current_text, + entities=extract_entities(current_text) + ) + ) + + logger.info("📚 自动翻页完成,共获取 %s 页", len(pages)) + return pages + + async def _wait_for_updated_message(self, previous_text: str, timeout: float = None) -> Optional[Message]: + assert self.client is not None + timeout = timeout or settings.pagination_timeout + start_time = time.time() + + while time.time() - start_time < timeout: + messages = await self.client.get_messages(self.bot_entity, limit=1) + if not messages: + await asyncio.sleep(0.4) + continue + + candidate = messages[0] + if candidate.out or not candidate.text: + await asyncio.sleep(0.4) + continue + + if candidate.text.strip() != (previous_text or "").strip(): + return candidate + + await asyncio.sleep(0.4) + + return None + + def _find_next_page_button(self, message: Message) -> Optional[Dict[str, Any]]: + if not message.reply_markup or not hasattr(message.reply_markup, "rows"): + return None + + button_index = 0 + for row in message.reply_markup.rows: + for button in row.buttons: + text = getattr(button, "text", "") + if text and any(keyword in text for keyword in settings.pagination_keywords): + return {"index": button_index, "text": text} + button_index += 1 + return None + + @staticmethod + def _format_pages(pages: List[PageRecord]) -> str: + if not pages: + return "" + formatted = [ + f"【第{page.page_number}页】\n{page.text}" + for page in pages + ] + return "\n\n".join(formatted) async def list_tools(self) -> List[Tool]: - """列出所有可用工具""" return [ Tool( name="funstat_search", - description="搜索 Telegram 群组、频道。支持关键词搜索,返回相关的群组列表", + description="搜索 Telegram 群组、频道,支持自动翻页", inputSchema={ "type": "object", "properties": { - "query": { - "type": "string", - "description": "搜索关键词,例如: 'python', '区块链', 'AI'" + "query": {"type": "string", "description": "搜索关键词"}, + "paginate": { + "type": "boolean", + "description": "是否自动翻页(默认开启)" + }, + "max_pages": { + "type": "integer", + "minimum": 1, + "maximum": settings.pagination_max_pages, + "description": "最大翻页数量" } }, "required": ["query"] @@ -515,10 +341,7 @@ class FunstatMCPServer: inputSchema={ "type": "object", "properties": { - "category": { - "type": "string", - "description": "分类筛选(可选),例如: 'tech', 'crypto', 'news'" - } + "category": {"type": "string", "description": "分类筛选(可选)"} } } ), @@ -528,10 +351,7 @@ class FunstatMCPServer: inputSchema={ "type": "object", "properties": { - "text": { - "type": "string", - "description": "要搜索的文本内容" - } + "text": {"type": "string", "description": "搜索文本"} }, "required": ["text"] } @@ -542,170 +362,153 @@ class FunstatMCPServer: inputSchema={ "type": "object", "properties": { - "name": { - "type": "string", - "description": "用户姓名" - } + "name": {"type": "string", "description": "姓名或关键词"} }, "required": ["name"] } ), Tool( name="funstat_user_info", - description="查询用户详细信息,支持通过用户名、用户ID、联系人等方式查询", + description="查询用户详细信息,支持用户名、用户ID或手机号", inputSchema={ "type": "object", "properties": { - "identifier": { - "type": "string", - "description": "用户标识: 用户名(@username)、用户ID、或手机号" - } + "identifier": {"type": "string", "description": "用户标识"} }, "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="查询当前账号的积分余额和使用统计", - 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": {} - } - ) + Tool(name="funstat_balance", description="查询当前账号的积分余额和使用统计"), + Tool(name="funstat_menu", description="显示 funstat BOT 的主菜单"), + Tool(name="funstat_start", description="获取 funstat BOT 的欢迎信息"), ] async def call_tool(self, name: str, arguments: Dict[str, Any]) -> List[TextContent]: - """调用工具""" - logger.info(f"🔧 调用工具: {name} with {arguments}") - + logger.info("🔧 调用工具: %s 参数=%s", name, 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)] + paginate_flag = arguments.get("paginate", True) + max_pages = arguments.get("max_pages") + bot_command = f"/search {query}" + response = await self.send_command_and_wait( + bot_command, + paginate=paginate_flag, + max_pages=max_pages + ) + await self._persist_response( + name, + bot_command, + {"query": query, "paginate": paginate_flag, "max_pages": max_pages}, + response + ) + return [TextContent(type="text", text=response.text)] - 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)] + if name == "funstat_topchat": + category = arguments.get("category", "").strip() + bot_command = f"/topchat {category}".strip() + response = await self.send_command_and_wait(bot_command) + await self._persist_response( + name, + bot_command, + {"category": category} if category else {}, + response + ) + return [TextContent(type="text", text=response.text)] - elif name == "funstat_text": + if name == "funstat_text": text = arguments["text"] - response = await self.send_command_and_wait(f"/text {text}") - return [TextContent(type="text", text=response)] + bot_command = f"/text {text}" + response = await self.send_command_and_wait(bot_command) + await self._persist_response(name, bot_command, {"text": text}, response) + return [TextContent(type="text", text=response.text)] - elif name == "funstat_human": + if name == "funstat_human": name_query = arguments["name"] - response = await self.send_command_and_wait(f"/human {name_query}") - return [TextContent(type="text", text=response)] + bot_command = f"/human {name_query}" + response = await self.send_command_and_wait(bot_command) + await self._persist_response(name, bot_command, {"name": name_query}, response) + return [TextContent(type="text", text=response.text)] - elif name == "funstat_user_info": + if name == "funstat_user_info": identifier = arguments["identifier"].strip() if not identifier: raise ValueError("用户标识不能为空") + bot_command = f"/user_info {identifier}" + response = await self.send_command_and_wait(bot_command, use_cache=False) + await self._persist_response(name, bot_command, {"identifier": identifier}, response) + return [TextContent(type="text", text=response.text)] - # funstat BOT 需要显式的 /user_info 命令 - response = await self.send_command_and_wait(f"/user_info {identifier}") - return [TextContent(type="text", text=response)] + if name == "funstat_balance": + bot_command = "/balance" + response = await self.send_command_and_wait(bot_command, use_cache=False) + await self._persist_response(name, bot_command, {}, response) + return [TextContent(type="text", text=response.text)] - 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)] + if name == "funstat_menu": + bot_command = "/menu" + response = await self.send_command_and_wait(bot_command) + await self._persist_response(name, bot_command, {}, response) + return [TextContent(type="text", text=response.text)] - elif name == "funstat_balance": - response = await self.send_command_and_wait("/balance") - return [TextContent(type="text", text=response)] + if name == "funstat_start": + bot_command = "/start" + response = await self.send_command_and_wait(bot_command) + await self._persist_response(name, bot_command, {}, response) + return [TextContent(type="text", text=response.text)] - elif name == "funstat_menu": - response = await self.send_command_and_wait("/menu") - return [TextContent(type="text", text=response)] + raise ValueError(f"未知工具: {name}") - elif name == "funstat_start": - response = await self.send_command_and_wait("/start") - return [TextContent(type="text", text=response)] + except Exception as exc: + logger.error("❌ 工具调用失败: %s", exc, exc_info=exc) + return [TextContent(type="text", text=f"❌ 错误: {exc}")] - else: - raise ValueError(f"未知工具: {name}") - - except Exception as e: - logger.error(f"❌ 工具调用失败: {e}") - return [TextContent( - type="text", - text=f"❌ 错误: {str(e)}" - )] + async def _persist_response( + self, + mcp_command: str, + bot_command: str, + arguments: Dict[str, Any], + response: BotResponse + ): + if not response.pages: + return + inserted = await self.storage.save_response( + command=mcp_command, + bot_command=bot_command, + arguments=arguments, + pages=response.pages, + source_account=self.account_display + ) + if inserted: + logger.info("💾 已写入 %s 条记录到本地数据库", len(inserted)) async def run(self): - """运行服务器""" + await self.storage.initialize() + if self.uploader: + await self.uploader.start() + await self.initialize() - # 启动定期清理过期缓存的任务 async def cache_cleanup_task(): while True: - await asyncio.sleep(300) # 每5分钟清理一次 + await asyncio.sleep(300) 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 响应 + is_json_response_enabled=True, ) - # 在后台运行 MCP 服务器 async def run_mcp_server(): async with transport.connect() as streams: await self.server.run( @@ -714,28 +517,19 @@ class FunstatMCPServer: 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("🌐 启动 SSE 服务器: http://%s:%s", settings.host, settings.port) + logger.info("📡 SSE 端点: http://%s:%s/sse", settings.host, settings.port) + logger.info("📨 消息端点: http://%s:%s/messages", settings.host, settings.port) - 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, + host=settings.host, + port=settings.port, log_level="info" ) server_instance = uvicorn.Server(config) @@ -743,9 +537,11 @@ class FunstatMCPServer: async def main(): - """主函数""" server = FunstatMCPServer() - await server.run() + try: + await server.run() + finally: + await server.storage.close() if __name__ == "__main__": diff --git a/core/start_server.sh b/core/start_server.sh deleted file mode 100755 index a2b3ceb..0000000 --- a/core/start_server.sh +++ /dev/null @@ -1,64 +0,0 @@ -#!/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 diff --git a/core/start_sse.sh b/core/start_sse.sh index 8f7cffa..f2dd2b0 100755 --- a/core/start_sse.sh +++ b/core/start_sse.sh @@ -5,7 +5,7 @@ echo "🚀 启动 Funstat MCP SSE 服务器..." echo "" # 设置环境变量 -export FUNSTAT_PORT=8091 +export FUNSTAT_PORT=8094 export FUNSTAT_HOST=127.0.0.1 # 检查依赖 diff --git a/core/start_sse_prod.sh b/core/start_sse_prod.sh index c3403da..9c50f27 100755 --- a/core/start_sse_prod.sh +++ b/core/start_sse_prod.sh @@ -28,19 +28,19 @@ sleep 3 # 验证启动 if ps -p $SERVER_PID > /dev/null; then echo "✅ 服务器已启动 (PID: $SERVER_PID)" - echo "📡 SSE 端点: http://127.0.0.1:8091/sse" + echo "📡 SSE 端点: http://127.0.0.1:8094/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 + if curl -s -o /dev/null -w "%{http_code}" -H 'Accept: text/event-stream' http://127.0.0.1:8094/sse | grep -Eq "200|204|206"; then echo "✅ GET /sse 测试通过" else echo "❌ GET /sse 测试失败" fi - if curl -s -o /dev/null -w "%{http_code}" -X POST http://127.0.0.1:8091/sse -H 'Content-Type: application/json' -d '{}' | grep -q "200"; then + if curl -s -o /dev/null -w "%{http_code}" -X POST http://127.0.0.1:8094/sse -H 'Content-Type: application/json' -H 'Accept: application/json' -d '{}' | grep -q "200"; then echo "✅ POST /sse 测试通过" else echo "❌ POST /sse 测试失败" @@ -49,7 +49,7 @@ if ps -p $SERVER_PID > /dev/null; then echo "" echo "📊 服务器状态:" echo " 进程ID: $SERVER_PID" - echo " 监听地址: http://127.0.0.1:8091" + echo " 监听地址: http://127.0.0.1:8094" echo " 日志: tail -f /tmp/funstat_sse.log" else echo "❌ 服务器启动失败!" diff --git a/core/storage.py b/core/storage.py new file mode 100644 index 0000000..328f765 --- /dev/null +++ b/core/storage.py @@ -0,0 +1,228 @@ +import asyncio +import json +import hashlib +from typing import Any, Dict, List, Optional + +import asyncpg + +from models import PageRecord + + +class StorageManager: + def __init__(self, database_url: str): + self.database_url = database_url + self.pool: Optional[asyncpg.pool.Pool] = None + self._lock = asyncio.Lock() + + async def initialize(self): + if self.pool: + return + self.pool = await asyncpg.create_pool( + self.database_url, + min_size=1, + max_size=5, + timeout=10 + ) + await self._ensure_schema() + + async def close(self): + if self.pool: + await self.pool.close() + self.pool = None + + async def _ensure_schema(self): + assert self.pool is not None + async with self.pool.acquire() as conn: + await conn.execute( + """ + CREATE TABLE IF NOT EXISTS mcp_results ( + id BIGSERIAL PRIMARY KEY, + command TEXT NOT NULL, + bot_command TEXT NOT NULL, + arguments JSONB NOT NULL, + arguments_hash TEXT NOT NULL, + page_number INTEGER NOT NULL, + raw_text TEXT NOT NULL, + raw_text_hash TEXT NOT NULL, + entities JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + source_account TEXT, + synced BOOLEAN NOT NULL DEFAULT FALSE, + sync_batch_id TEXT, + last_sync_at TIMESTAMPTZ, + sync_attempts INTEGER NOT NULL DEFAULT 0, + UNIQUE (command, bot_command, page_number, arguments_hash, raw_text_hash) + ); + """ + ) + + await conn.execute( + """ + CREATE TABLE IF NOT EXISTS mcp_entities ( + id BIGSERIAL PRIMARY KEY, + result_id BIGINT NOT NULL REFERENCES mcp_results (id) ON DELETE CASCADE, + entity_type TEXT NOT NULL, + entity_value TEXT NOT NULL, + metadata JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (result_id, entity_type, entity_value) + ); + """ + ) + + async def save_response( + self, + command: str, + bot_command: str, + arguments: Dict[str, Any], + pages: List[PageRecord], + source_account: Optional[str] = None + ) -> List[int]: + if not pages or not self.pool: + return [] + + arguments_json = self._normalize_arguments(arguments) + arguments_hash = self._hash_value(arguments_json) + inserted_ids: List[int] = [] + + async with self.pool.acquire() as conn: + async with conn.transaction(): + for page in pages: + raw_text = page.text or "" + raw_hash = self._hash_value(raw_text) + + record = await conn.fetchrow( + """ + INSERT INTO mcp_results ( + command, bot_command, arguments, arguments_hash, + page_number, raw_text, raw_text_hash, entities, + source_account + ) VALUES ($1,$2,$3::jsonb,$4,$5,$6,$7,$8::jsonb,$9) + ON CONFLICT (command, bot_command, page_number, arguments_hash, raw_text_hash) + DO NOTHING + RETURNING id; + """, + command, + bot_command, + arguments_json, + arguments_hash, + page.page_number, + raw_text, + raw_hash, + json.dumps(page.entities or [], ensure_ascii=False), + source_account, + ) + + if record: + result_id = record["id"] + inserted_ids.append(result_id) + if page.entities: + await self._upsert_entities(conn, result_id, page.entities) + + return inserted_ids + + async def fetch_unsynced_results(self, limit: int = 200) -> List[Dict[str, Any]]: + if not self.pool: + return [] + + async with self.pool.acquire() as conn: + rows = await conn.fetch( + """ + SELECT + r.id, + r.command, + r.bot_command, + r.arguments, + r.page_number, + r.raw_text, + r.entities, + r.created_at, + r.source_account, + COALESCE( + json_agg( + json_build_object( + 'entity_type', e.entity_type, + 'entity_value', e.entity_value, + 'metadata', e.metadata + ) + ) FILTER (WHERE e.id IS NOT NULL), + '[]'::json + ) AS entity_list + FROM mcp_results r + LEFT JOIN mcp_entities e ON e.result_id = r.id + WHERE r.synced = FALSE + GROUP BY r.id + ORDER BY r.created_at ASC + LIMIT $1; + """, + limit, + ) + + payload = [] + for row in rows: + payload.append({ + "id": row["id"], + "command": row["command"], + "bot_command": row["bot_command"], + "arguments": row["arguments"], + "page_number": row["page_number"], + "raw_text": row["raw_text"], + "entities": row["entity_list"], + "created_at": row["created_at"].isoformat() if row["created_at"] else None, + "source_account": row["source_account"], + }) + return payload + + async def mark_synced(self, ids: List[int], batch_id: str): + if not ids or not self.pool: + return + + async with self.pool.acquire() as conn: + await conn.execute( + """ + UPDATE mcp_results + SET synced = TRUE, + sync_batch_id = $1, + last_sync_at = NOW(), + sync_attempts = 0 + WHERE id = ANY($2::bigint[]); + """, + batch_id, + ids, + ) + + async def mark_failed_sync(self, ids: List[int]): + if not ids or not self.pool: + return + + async with self.pool.acquire() as conn: + await conn.execute( + """ + UPDATE mcp_results + SET sync_attempts = sync_attempts + 1 + WHERE id = ANY($1::bigint[]); + """, + ids, + ) + + async def _upsert_entities(self, conn: asyncpg.Connection, result_id: int, entities: List[Dict[str, Any]]): + for entity in entities: + await conn.execute( + """ + INSERT INTO mcp_entities (result_id, entity_type, entity_value, metadata) + VALUES ($1, $2, $3, $4::jsonb) + ON CONFLICT (result_id, entity_type, entity_value) DO NOTHING; + """, + result_id, + entity.get("type", "unknown"), + str(entity.get("value", "")), + json.dumps(entity.get("metadata", {}), ensure_ascii=False), + ) + + @staticmethod + def _normalize_arguments(arguments: Dict[str, Any]) -> str: + return json.dumps(arguments or {}, ensure_ascii=False, sort_keys=True, default=str) + + @staticmethod + def _hash_value(value: str) -> str: + return hashlib.sha256(value.encode("utf-8")).hexdigest() diff --git a/core/test_codex_connection.sh b/core/test_codex_connection.sh index 7b767a7..d045398 100755 --- a/core/test_codex_connection.sh +++ b/core/test_codex_connection.sh @@ -16,7 +16,7 @@ fi # 2. 测试 SSE 端点 echo "" echo "2. 测试 SSE 端点 (GET /sse)..." -response=$(timeout 2 curl -s -N -H "Accept: text/event-stream" http://127.0.0.1:8091/sse 2>&1 | head -3) +response=$(timeout 2 curl -s -N -H "Accept: text/event-stream" http://127.0.0.1:8094/sse 2>&1 | head -3) if echo "$response" | grep -q "event: endpoint"; then echo "✓ SSE 端点响应正常" echo "$response" | grep "data:" | head -1 diff --git a/core/uploader.py b/core/uploader.py new file mode 100644 index 0000000..1e053c1 --- /dev/null +++ b/core/uploader.py @@ -0,0 +1,128 @@ +import asyncio +import json +import logging +import tempfile +import uuid +from datetime import datetime, timezone +from pathlib import Path +from typing import List + +from .config import Settings +from .storage import StorageManager + + +logger = logging.getLogger("funstat_uploader") + + +class RemoteUploader: + def __init__(self, storage: StorageManager, settings: Settings): + self.storage = storage + self.settings = settings + self.enabled = ( + settings.remote_upload_enabled + and settings.remote_ssh_host + and settings.remote_ssh_user + and settings.remote_ssh_password + ) + self.interval = max(settings.remote_upload_interval, 30) + self.batch_size = max(settings.remote_upload_batch_size, 10) + self._task: asyncio.Task | None = None + self._ensure_task: asyncio.Task | None = None + + async def start(self): + if not self.enabled or self._task: + return + + await self._ensure_remote_dir() + self._task = asyncio.create_task(self._run_loop()) + + async def _run_loop(self): + while True: + try: + await self._process_batch() + except Exception as exc: + logger.error("远程上传任务异常: %s", exc, exc_info=exc) + await asyncio.sleep(self.interval) + + async def _process_batch(self): + results = await self.storage.fetch_unsynced_results(self.batch_size) + if not results: + return + + batch_id = uuid.uuid4().hex + tmp_path = await self._write_payload(batch_id, results) + try: + uploaded = await self._upload_file(tmp_path, batch_id) + ids = [item["id"] for item in results] + if uploaded: + await self.storage.mark_synced(ids, batch_id) + logger.info("成功同步 %s 条记录到远程服务器 (batch=%s)", len(ids), batch_id) + else: + await self.storage.mark_failed_sync(ids) + logger.warning("同步失败,已记录失败次数 (batch=%s)", batch_id) + finally: + if tmp_path.exists(): + tmp_path.unlink(missing_ok=True) + + async def _write_payload(self, batch_id: str, results: List[dict]) -> Path: + tmp_dir = Path(tempfile.gettempdir()) + file_path = tmp_dir / f"funstat_batch_{batch_id}.json" + payload = { + "batch_id": batch_id, + "total": len(results), + "generated_at": datetime.now(timezone.utc).isoformat(), + "results": results, + } + + file_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2)) + return file_path + + async def _ensure_remote_dir(self): + if not self.enabled: + return + + target_dir = self.settings.remote_ssh_target + command = ( + f"sshpass -p '{self.settings.remote_ssh_password}' " + f"ssh -o StrictHostKeyChecking=no " + f"{self.settings.remote_ssh_user}@{self.settings.remote_ssh_host} " + f"'mkdir -p {target_dir}'" + ) + process = await asyncio.create_subprocess_shell( + command, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, stderr = await process.communicate() + if process.returncode != 0: + logger.warning( + "创建远程目录失败: %s", + stderr.decode().strip() or stdout.decode().strip() + ) + + async def _upload_file(self, file_path: Path, batch_id: str) -> bool: + if not self.enabled: + return False + + remote_name = f"{batch_id}.json" + command = ( + f"sshpass -p '{self.settings.remote_ssh_password}' " + f"scp -o StrictHostKeyChecking=no {file_path} " + f"{self.settings.remote_ssh_user}@{self.settings.remote_ssh_host}:" + f"{self.settings.remote_ssh_target}/{remote_name}" + ) + process = await asyncio.create_subprocess_shell( + command, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, stderr = await process.communicate() + if process.returncode != 0: + logger.error( + "上传批次 %s 失败: %s", + batch_id, + stderr.decode().strip() or stdout.decode().strip() + ) + return False + + return True diff --git a/deploy.sh b/deploy.sh deleted file mode 100755 index ea66d0a..0000000 --- a/deploy.sh +++ /dev/null @@ -1,77 +0,0 @@ -#!/bin/bash -# Funstat MCP 服务器部署脚本 - -set -e - -SERVER_IP="172.16.74.159" -SERVER_USER="atai" -SERVER_PATH="/home/atai/funstat-mcp" -LOCAL_PATH="/Users/hahaha/projects/funstat-mcp" - -echo "🚀 开始部署 Funstat MCP 到服务器..." - -# 1. 打包项目文件(排除不需要的文件) -echo "📦 打包项目文件..." -cd "$LOCAL_PATH" -tar --exclude='.git' \ - --exclude='.venv' \ - --exclude='__pycache__' \ - --exclude='*.pyc' \ - --exclude='.DS_Store' \ - --exclude='customer_data' \ - -czf /tmp/funstat-mcp.tar.gz . - -# 2. 上传到服务器 -echo "⬆️ 上传文件到服务器..." -sshpass -p "wengewudi666808" scp /tmp/funstat-mcp.tar.gz ${SERVER_USER}@${SERVER_IP}:/tmp/ - -# 3. 在服务器上部署 -echo "🔧 在服务器上部署..." -sshpass -p "wengewudi666808" ssh ${SERVER_USER}@${SERVER_IP} << 'ENDSSH' -set -e - -# 创建部署目录 -mkdir -p /home/atai/funstat-mcp -cd /home/atai/funstat-mcp - -# 停止旧服务 -echo "🛑 停止旧服务..." -pkill -f "funstat.*server.py" 2>/dev/null || true -sleep 2 - -# 解压新文件 -echo "📂 解压新文件..." -tar -xzf /tmp/funstat-mcp.tar.gz -C /home/atai/funstat-mcp -rm /tmp/funstat-mcp.tar.gz - -# 创建虚拟环境(如果不存在) -if [ ! -d ".venv" ]; then - echo "🔨 创建虚拟环境..." - python3 -m venv .venv -fi - -# 激活虚拟环境并安装依赖 -echo "📥 安装依赖..." -source .venv/bin/activate -pip install --upgrade pip -pip install -r requirements.txt - -# 创建 session 目录 -mkdir -p ~/telegram_sessions - -# 检查 session 文件 -if [ ! -f ~/telegram_sessions/funstat_bot.session ]; then - echo "⚠️ 警告: Session 文件不存在" - echo "请确保已经上传 session 文件到 ~/telegram_sessions/funstat_bot.session" -fi - -echo "✅ 部署完成!" -echo "启动服务: cd /home/atai/funstat-mcp/core && bash start_server.sh" -ENDSSH - -echo "" -echo "✅ 部署完成!" -echo "下一步:" -echo "1. 确保 session 文件已上传到服务器: ~/telegram_sessions/funstat_bot.session" -echo "2. SSH 到服务器: ssh atai@172.16.74.159" -echo "3. 启动服务: cd /home/atai/funstat-mcp/core && bash start_server.sh" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..6a08b52 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,18 @@ +services: + postgres: + image: postgres:16-alpine + container_name: funstat-postgres + restart: unless-stopped + environment: + POSTGRES_DB: ${POSTGRES_DB:-funstat} + POSTGRES_USER: ${POSTGRES_USER:-funstat} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-funstat_dev_password} + ports: + - "${POSTGRES_PORT:-5433}:5432" + volumes: + - ./local_data/postgres:/var/lib/postgresql/data + healthcheck: + test: ["CMD", "pg_isready", "-U", "${POSTGRES_USER:-funstat}"] + interval: 10s + timeout: 5s + retries: 5 diff --git a/docs/CODEX_CLI_MCP_SETUP.md b/docs/CODEX_CLI_MCP_SETUP.md index 76cc939..3076cf3 100644 --- a/docs/CODEX_CLI_MCP_SETUP.md +++ b/docs/CODEX_CLI_MCP_SETUP.md @@ -18,7 +18,7 @@ **命令**: ```bash -codex mcp add --url http://127.0.0.1:8091/sse funstat +codex mcp add --url http://127.0.0.1:8094/sse funstat ``` **输出**: @@ -31,7 +31,7 @@ Added global MCP server 'funstat'. **配置详情**: ``` Name: funstat -URL: http://127.0.0.1:8091/sse +URL: http://127.0.0.1:8094/sse Transport: streamable_http Status: enabled Auth: Unsupported @@ -44,7 +44,7 @@ Auth: Unsupported **新增内容**: ```toml [mcp_servers.funstat] -url = "http://127.0.0.1:8091/sse" +url = "http://127.0.0.1:8094/sse" ``` --- @@ -80,7 +80,7 @@ codex mcp list **输出**: ``` Name Url Bearer Token Env Var Status Auth -funstat http://127.0.0.1:8091/sse - enabled Unsupported +funstat http://127.0.0.1:8094/sse - enabled Unsupported ``` ### 查看服务器详情 @@ -94,7 +94,7 @@ codex mcp get funstat funstat enabled: true transport: streamable_http - url: http://127.0.0.1:8091/sse + url: http://127.0.0.1:8094/sse bearer_token_env_var: - http_headers: - env_http_headers: - @@ -110,7 +110,7 @@ codex mcp remove funstat ### 重新添加 ```bash -codex mcp add --url http://127.0.0.1:8091/sse funstat +codex mcp add --url http://127.0.0.1:8094/sse funstat ``` --- @@ -200,12 +200,12 @@ codex -i screenshot.png "这个截图中的Telegram用户名是什么?帮我搜 └─────────────┬──────────────┘ │ │ SSE 连接 - │ http://127.0.0.1:8091/sse + │ http://127.0.0.1:8094/sse │ ┌─────────────▼──────────────────┐ │ Funstat MCP Server (SSE) │ │ funstat_mcp/server.py │ - │ 端口: 8091 │ + │ 端口: 8094 │ └─────────────┬──────────────────┘ │ │ Telethon @@ -232,7 +232,7 @@ export FUNSTAT_TOKEN="your-token-here" # 添加 MCP 服务器时指定 codex mcp add \ - --url http://127.0.0.1:8091/sse \ + --url http://127.0.0.1:8094/sse \ --bearer-token-env-var FUNSTAT_TOKEN \ funstat ``` @@ -243,7 +243,7 @@ codex mcp add \ ```toml [mcp_servers.funstat] -url = "http://127.0.0.1:8091/sse" +url = "http://127.0.0.1:8094/sse" # bearer_token_env_var = "FUNSTAT_TOKEN" # 可选 ``` @@ -264,7 +264,7 @@ url = "http://127.0.0.1:8091/sse" ps aux | grep server.py # 2. 测试 SSE 端点 -curl -i http://127.0.0.1:8091/sse +curl -i http://127.0.0.1:8094/sse # 3. 重启 SSE 服务器 cd /Users/lucas/chat--1003255561049/funstat_mcp diff --git a/local_data/.gitkeep b/local_data/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/local_data/.gitkeep @@ -0,0 +1 @@ + diff --git a/requirements.txt b/requirements.txt index dc499ca..bad4ea7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,8 +1,6 @@ -mcp>=1.12.0 -telethon>=1.34.0 -starlette>=0.41.0 -uvicorn>=0.29.0 -httpx>=0.28.0 -pydantic>=2.0.0 -python-dotenv>=1.0.0 -PySocks>=1.7.1 +mcp>=1.20.0 +telethon>=1.41.0 +starlette>=0.50.0 +uvicorn>=0.38.0 +asyncpg>=0.29.0 +python-dotenv>=1.0.1 diff --git a/scripts/check_history.py b/scripts/check_history.py index b29542f..691e071 100644 --- a/scripts/check_history.py +++ b/scripts/check_history.py @@ -2,10 +2,17 @@ """ 查看与 BOT 的历史消息 """ -import requests import json +import os -BOT_TOKEN = "8410096573:AAFLJbWUp2Xog0oeoe7hfBlVqR7ChoSl9Pg" +import requests + +from env_loader import load_env + +load_env() +BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN") +if not BOT_TOKEN: + raise RuntimeError("请在 .env 中设置 TELEGRAM_BOT_TOKEN") BASE_URL = f"https://api.telegram.org/bot{BOT_TOKEN}" def get_updates(offset=None, limit=100): diff --git a/scripts/check_webhook.py b/scripts/check_webhook.py index 78a4209..ff19e58 100644 --- a/scripts/check_webhook.py +++ b/scripts/check_webhook.py @@ -2,10 +2,17 @@ """ 检查并配置 Webhook """ -import requests import json +import os -BOT_TOKEN = "8410096573:AAFLJbWUp2Xog0oeoe7hfBlVqR7ChoSl9Pg" +import requests + +from env_loader import load_env + +load_env() +BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN") +if not BOT_TOKEN: + raise RuntimeError("请在 .env 中设置 TELEGRAM_BOT_TOKEN") BASE_URL = f"https://api.telegram.org/bot{BOT_TOKEN}" def get_webhook_info(): diff --git a/scripts/create_session_safe.py b/scripts/create_session_safe.py index 03ef841..d50e5a6 100755 --- a/scripts/create_session_safe.py +++ b/scripts/create_session_safe.py @@ -9,16 +9,22 @@ Telethon Session 创建脚本(安全版本) import asyncio import os from pathlib import Path + from telethon import TelegramClient from telethon.errors import SessionPasswordNeededError -# 你的 API 凭证 -API_ID = 24660516 -API_HASH = "eae564578880a59c9963916ff1bbbd3a" +from env_loader import load_env -# Session 文件保存位置 - 独立的安全目录 -SESSION_DIR = Path.home() / "telegram_sessions" -SESSION_PATH = SESSION_DIR / "funstat_bot" +load_env() + +API_ID = int(os.getenv("TELEGRAM_API_ID", "0") or 0) +API_HASH = os.getenv("TELEGRAM_API_HASH", "") +SESSION_BASE = os.path.expanduser(os.getenv("TELEGRAM_SESSION_PATH", str(Path.home() / "telegram_sessions" / "funstat_bot"))) +SESSION_PATH = Path(SESSION_BASE) +SESSION_DIR = SESSION_PATH.parent + +if not API_ID or not API_HASH: + raise RuntimeError("请在 .env 中设置 TELEGRAM_API_ID 和 TELEGRAM_API_HASH") async def create_session(): """创建 Telegram session 文件""" @@ -29,7 +35,7 @@ async def create_session(): print() # 创建 session 目录 - SESSION_DIR.mkdir(exist_ok=True) + SESSION_DIR.mkdir(parents=True, exist_ok=True) print(f"📁 Session 目录: {SESSION_DIR}") print() diff --git a/scripts/env_loader.py b/scripts/env_loader.py new file mode 100644 index 0000000..18998b7 --- /dev/null +++ b/scripts/env_loader.py @@ -0,0 +1,11 @@ +from pathlib import Path + +from dotenv import load_dotenv + + +def load_env(): + base_dir = Path(__file__).resolve().parents[1] + dotenv_path = base_dir / ".env" + if dotenv_path.exists(): + load_dotenv(dotenv_path) + diff --git a/scripts/explore_bot.py b/scripts/explore_bot.py index 807a13c..fd2950a 100644 --- a/scripts/explore_bot.py +++ b/scripts/explore_bot.py @@ -2,11 +2,18 @@ """ 探索 Telegram BOT 的功能 """ -import requests import json +import os import time -BOT_TOKEN = "8410096573:AAFLJbWUp2Xog0oeoe7hfBlVqR7ChoSl9Pg" +import requests + +from env_loader import load_env + +load_env() +BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN") +if not BOT_TOKEN: + raise RuntimeError("请在 .env 中设置 TELEGRAM_BOT_TOKEN") BASE_URL = f"https://api.telegram.org/bot{BOT_TOKEN}" def get_bot_info(): diff --git a/scripts/interact_with_bot.py b/scripts/interact_with_bot.py index 5f85989..b4b5018 100644 --- a/scripts/interact_with_bot.py +++ b/scripts/interact_with_bot.py @@ -2,12 +2,19 @@ """ 交互式 Telegram BOT 测试工具 """ -import requests import json -import time import sys +import time +import os -BOT_TOKEN = "8410096573:AAFLJbWUp2Xog0oeoe7hfBlVqR7ChoSl9Pg" +import requests + +from env_loader import load_env + +load_env() +BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN") +if not BOT_TOKEN: + raise RuntimeError("请在 .env 中设置 TELEGRAM_BOT_TOKEN") BASE_URL = f"https://api.telegram.org/bot{BOT_TOKEN}" # 这里需要你的 Telegram 用户 ID diff --git a/scripts/test_all_commands.py b/scripts/test_all_commands.py index c7a9806..5965037 100644 --- a/scripts/test_all_commands.py +++ b/scripts/test_all_commands.py @@ -3,12 +3,19 @@ 测试 funstat BOT 的所有命令 基于截图发现的功能 """ -import requests import json -import time +import os import sys +import time -BOT_TOKEN = "8410096573:AAFLJbWUp2Xog0oeoe7hfBlVqR7ChoSl9Pg" +import requests + +from env_loader import load_env + +load_env() +BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN") +if not BOT_TOKEN: + raise RuntimeError("请在 .env 中设置 TELEGRAM_BOT_TOKEN") BASE_URL = f"https://api.telegram.org/bot{BOT_TOKEN}" # 测试用的免费 ID(从 BOT 提供) diff --git a/scripts/test_bot_commands.py b/scripts/test_bot_commands.py index b107a74..9a051f6 100644 --- a/scripts/test_bot_commands.py +++ b/scripts/test_bot_commands.py @@ -2,11 +2,18 @@ """ 测试 BOT 的所有命令并获取响应 """ -import requests import json +import os import time -BOT_TOKEN = "8410096573:AAFLJbWUp2Xog0oeoe7hfBlVqR7ChoSl9Pg" +import requests + +from env_loader import load_env + +load_env() +BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN") +if not BOT_TOKEN: + raise RuntimeError("请在 .env 中设置 TELEGRAM_BOT_TOKEN") BASE_URL = f"https://api.telegram.org/bot{BOT_TOKEN}" def get_updates(offset=None): diff --git a/test_connection.sh b/test_connection.sh deleted file mode 100755 index 31b7d9e..0000000 --- a/test_connection.sh +++ /dev/null @@ -1,45 +0,0 @@ -#!/bin/bash -# 测试 Funstat MCP 服务器连接 - -SERVER_HOST="${1:-172.16.74.159}" -SERVER_PORT="${2:-8091}" - -echo "测试 Funstat MCP 服务器连接..." -echo "服务器: $SERVER_HOST:$SERVER_PORT" -echo "" - -# 测试 1: TCP 连接 -echo "1. 测试 TCP 连接..." -if timeout 3 bash -c "cat < /dev/null > /dev/tcp/$SERVER_HOST/$SERVER_PORT" 2>/dev/null; then - echo " ✅ TCP 连接成功" -else - echo " ❌ TCP 连接失败" - exit 1 -fi - -# 测试 2: HTTP GET 请求 -echo "" -echo "2. 测试 HTTP GET 请求..." -HTTP_CODE=$(curl -s -o /dev/null -w '%{http_code}' --connect-timeout 5 http://$SERVER_HOST:$SERVER_PORT/sse) -if [ "$HTTP_CODE" = "406" ] || [ "$HTTP_CODE" = "200" ]; then - echo " ✅ HTTP 响应: $HTTP_CODE (服务器正常)" -else - echo " ❌ HTTP 响应: $HTTP_CODE" -fi - -# 测试 3: SSE 端点 -echo "" -echo "3. 测试 SSE 端点(Accept: text/event-stream)..." -RESPONSE=$(timeout 3 curl -s -H "Accept: text/event-stream" http://$SERVER_HOST:$SERVER_PORT/sse 2>&1 | head -1) -if [ -n "$RESPONSE" ]; then - echo " ✅ SSE 端点响应: $RESPONSE" -else - echo " ⚠️ SSE 端点正在等待连接(正常)" -fi - -echo "" -echo "✅ 服务器 $SERVER_HOST:$SERVER_PORT 可以正常访问!" -echo "" -echo "访问地址:" -echo " - SSE 端点: http://$SERVER_HOST:$SERVER_PORT/sse" -echo " - 消息端点: http://$SERVER_HOST:$SERVER_PORT/messages" diff --git a/translation_search_翻译.csv b/translation_search_翻译.csv new file mode 100644 index 0000000..39ccb1a --- /dev/null +++ b/translation_search_翻译.csv @@ -0,0 +1,101 @@ +序号,类型,成员数,链接/ID,原文标题,英文翻译 +1,频道,660469,ID 1710983297,zh_CN 中文语言包 中文安装包 中文翻..,"zh_CN Chinese language pack, Chinese installer, Chinese translation …" +2,频道,584550,ID 1566678243,中文翻译|中文搜索|中文导航群,Chinese translation / Chinese search / Chinese directory group +3,频道,438211,https://t.me/zh_cnm2f,Tel℮ցram-中文翻译-简体中文语言包,Telegram Chinese translation Simplified Chinese language pack +4,频道,438207,https://t.me/zh_cnwfs,Тelеցrαm-中文设置-中文翻译-简体..,"Telegram Chinese settings, Chinese translation, Simplified …" +5,频道,417088,https://t.me/zh_cna6j,zh_cn 简体中文 中文翻译 中文汉化..,"zh_cn Simplified Chinese, Chinese translation, Chinese localization …" +6,频道,379036,ID 2150536944,zh_CN 中文翻译包 中文安装包 中文汉化包,"zh_CN Chinese translation pack, Chinese installer, Chinese localization pack" +7,频道,367296,ID 1583787456,中文翻译语言包,Chinese translation language pack +8,频道,330244,ID 2148105977,zh_CN 中文语言 中文翻译 中文安装..,"zh_CN Chinese language, Chinese translation, Chinese installation …" +9,频道,322010,ID 2223733474,zh_ϹN 中文语言 中文翻译 中文安装..,"zh_CN Chinese language, Chinese translation, Chinese installation …" +10,频道,302241,ID 2148451952,zh_ϹN 中文语言 中文翻译 中文安装..,"zh_CN Chinese language, Chinese translation, Chinese installation …" +11,频道,279123,https://t.me/zhcng,zh_CN 中文翻译 简体中文 中文简体,"zh_CN Chinese translation, Simplified Chinese, Chinese simplified" +12,频道,252698,ID 1774109250,ZH-ϹN 中文安装包 中文语言包 中文..,"ZH_CN Chinese installer pack, Chinese language pack, Chinese …" +13,频道,247543,ID 2194406110,zh_CN 中文语言 中文翻译 中文安装..,"zh_CN Chinese language, Chinese translation, Chinese installation …" +14,频道,224238,https://t.me/zhongwenbao_setlanguage0e,zh_сη 简体中文 中文翻译包,"zh_cn Simplified Chinese, Chinese translation pack" +15,未知,N/A,https://t.me/cn_zh1,简体中文包 中文安装包 中文翻译包 zh..,"Simplified Chinese pack, Chinese installer, Chinese translation pack zh…" +16,频道,222378,https://t.me/zhongwenbao_setlanguage0d,zh_cn 中文翻译包 中文安装包,"zh_cn Chinese translation pack, Chinese installer pack" +17,频道,191801,ID 1144415959,中文安装包 中文语言包 中文翻译 中文频..,"Chinese installer pack, Chinese language pack, Chinese translation, Chinese channel …" +18,频道,171299,ID 1916164807,zh_cη 中文安装 简体中文 中文翻译,"zh_cn Chinese installation, Simplified Chinese, Chinese translation" +19,频道,157577,https://t.me/zh_cnm9w,中文安装包 简体中文 中文翻译 zhon..,"Chinese installer pack, Simplified Chinese, Chinese translation zh…" +20,频道,156840,https://t.me/zh_cn2345ccc,zh_сn 中文设置 中文翻译包 简体中..,"zh_cn Chinese settings, Chinese translation pack, Simplified Chinese …" +21,频道,141723,ID 1915735619,中文翻译 中文设置 中文汉化,"Chinese translation, Chinese settings, Chinese localization" +22,频道,128945,ID 1827710116,zh_CN中文翻译包 中文设置 中文转换..,"zh_CN Chinese translation pack, Chinese settings, Chinese conversion …" +23,未知,N/A,https://t.me/zh_cn26x,Telegrαm-zh_CN 简体中文..,Telegram zh_CN Simplified Chinese … +24,未知,N/A,https://t.me/zh_cnaep,zhсη 中文简体 简体中文 中文翻译,"zh_cn Simplified Chinese, Simplified Chinese, Chinese translation" +25,未知,N/A,https://t.me/zh_cn05y,zh_cη 简体中文包 中文翻译包 zh..,"zh_cn Simplified Chinese pack, Chinese translation pack zh…" +26,私有,N/A,ID 2119919233,zh_ϹN 中文安装包 中文翻译包 简体..,"zh_CN Chinese installer pack, Chinese translation pack, Simplified …" +27,未知,N/A,https://t.me/zh_cnqq123,zh-CN 中文设置 中文翻译 简体中文,"zh-CN Chinese settings, Chinese translation, Simplified Chinese" +28,未知,N/A,https://t.me/DSFDSFWS,Tеłegrαm🔥 中文丨中文包丨汉化..,Telegram Chinese / Chinese pack / localization … +29,未知,N/A,https://t.me/setlanguage_zhongwenbaoh,中文设置 中文翻译包 ch_zη,"Chinese settings, Chinese translation pack ch_zn" +30,未知,N/A,https://t.me/qerk5,中文翻译【中文包】中文汉化 中文转换 聪..,Chinese translation [Chinese pack] Chinese localization Chinese conversion … +31,频道,124944,ID 1076212650,@zhongwen 中文语言安装包🅥汉..,@zhongwen Chinese language installer pack V Chinese … +32,频道,121811,ID 1590476062,zh_cn 中文简体 中文汉化 中文翻译,"zh_cn Simplified Chinese, Chinese localization, Chinese translation" +33,频道,120042,ID 1706645578,zh_ϹN 简体中文 中文翻译 中文设置..,"zh_CN Simplified Chinese, Chinese translation, Chinese settings …" +34,频道,116483,ID 1974111504,zh_cn 简体中文包 中文安装包 中文..,"zh_cn Simplified Chinese pack, Chinese installer pack, Chinese …" +35,频道,116252,ID 1240579220,zh_CN 简体中文 中文翻译 中文安装..,"zh_CN Simplified Chinese, Chinese translation, Chinese installation …" +36,频道,111703,ID 1336825472,简体中文包 中文翻译包 中文汉化包 飞机..,"Simplified Chinese pack, Chinese translation pack, Chinese localization pack for Telegram" +37,频道,108625,ID 1820093287,zh_сn 中文翻译语言包,zh_cn Chinese translation language pack +38,频道,104172,ID 1098703312,zh_cη 简体中文 中文设置 中文语言..,"zh_cn Simplified Chinese, Chinese settings, Chinese language …" +39,频道,104020,ID 1366688177,zh_CN 中文设置 中文翻译 简体中文..,"zh_CN Chinese settings, Chinese translation, Simplified Chinese …" +40,频道,103390,ID 1100945858,简体中文包 中文翻译包 中文汉化包 飞机..,"Simplified Chinese pack, Chinese translation pack, Chinese localization pack for Telegram" +41,未知,N/A,ID 1161864298,zh_СN 中文安装包 简体中文包 中文..,"zh_CN Chinese installer pack, Simplified Chinese pack, Chinese …" +42,未知,N/A,ID 2567113236,zhcn 中文简体 中文翻译包 中文汉化..,"zhcn Simplified Chinese, Chinese translation pack, Chinese localization …" +43,未知,N/A,ID 2570705953,江湖丨中文包丨中文翻译丨简体中文丨中文安..,"Jianghu Chinese pack, Chinese translation, Simplified Chinese, Chinese …" +44,未知,N/A,ID 2589631230,中文翻译包 中文汉化 ch_zη,"Chinese translation pack, Chinese localization ch_zn" +45,未知,N/A,ID 2659302988,中文翻译包 简体中文包 zhcn,"Chinese translation pack, Simplified Chinese pack zhcn" +46,频道,100872,ID 1733743094,zh_cη 简体中文 中文安装 中文翻译..,"zh_cn Simplified Chinese, Chinese installation, Chinese translation …" +47,频道,100306,ID 1440807004,zh_ϹN 中文翻译 中文安装 简体中文..,"zh_CN Chinese translation, Chinese installation, Simplified Chinese …" +48,频道,93248,ID 1773112742,zh_CN 中文设置 简体中文 中文翻译..,"zh_CN Chinese settings, Simplified Chinese, Chinese translation …" +49,频道,92296,ID 1815877901,中文翻译 中文设置 中文汉化,"Chinese translation, Chinese settings, Chinese localization" +50,频道,86816,ID 1628283283,zh_cn 中文汉化 简体中文 中文安装..,"zh_cn Chinese localization, Simplified Chinese, Chinese installation …" +51,频道,85543,ID 1006936145,免费翻墙VPN加速器🚀,Free VPN circumvention accelerator 🚀 +52,频道,84931,ID 1800001976,Telegram-zh_CN 简体中文语..,Telegram zh_CN Simplified Chinese language … +53,频道,78176,ID 1168481222,zh_CN 中文安装包 中文翻译包 简体..,"zh_CN Chinese installer pack, Chinese translation pack, Simplified …" +54,频道,72997,ID 1795820545,zh_cη 简体中文包 中文翻译 中文安..,"zh_cn Simplified Chinese pack, Chinese translation, Chinese …" +55,未知,N/A,ID 2376777267,zh_CN 简体中文语言包 中文安装 中..,"zh_CN Simplified Chinese language pack, Chinese installation, Chinese …" +56,未知,N/A,ID 2525444669,简体中文包 中文翻译包 中文汉化包 飞机..,"Simplified Chinese pack, Chinese translation pack, Chinese localization pack for Telegram" +57,未知,N/A,ID 1729040890,zh_ϹN 中文翻译 中文安装 简体中文..,"zh_CN Chinese translation, Chinese installation, Simplified Chinese …" +58,未知,N/A,ID 2552426535,τg中文包 飞机中文包 飞机翻译包 中文..,"TG Chinese pack, Telegram Chinese pack, Telegram translation pack Chinese …" +59,私有,N/A,ID 1722627298,zh_cη 简体中文 中文安装 中文翻译..,"zh_cn Simplified Chinese, Chinese installation, Chinese translation …" +60,私有,N/A,ID 1844633120,zh_CN 中文语言包 中文汉化 中文翻..,"zh_CN Chinese language pack, Chinese localization, Chinese translation …" +61,频道,58940,ID 1996471747,zh_CN-中文安装包 中文语言包 中文..,"zh_CN Chinese installer pack, Chinese language pack, Chinese …" +62,频道,52267,ID 1663378188,Teⅼergαm-zh_CN 中文语言翻译包,Telegram zh_CN Chinese language translation pack +63,私有,N/A,ID 1632218770,zh_CN 简体中文 中文汉化 中文简体..,"zh_CN Simplified Chinese, Chinese localization, Chinese simplified …" +64,未知,N/A,ID 1947167790,zh_cn 中文简体包 中文语言包 中文..,"zh_cn Simplified Chinese pack, Chinese language pack, Chinese …" +65,未知,N/A,ID 2121686594,zh_CN 中文语言包 中文安装包 中文..,"zh_CN Chinese language pack, Chinese installer pack, Chinese …" +66,私有,N/A,ID 2135222179,zh_CN 简体中文翻译语言包 中文频道..,"zh_CN Simplified Chinese translation language pack, Chinese channel …" +67,私有,N/A,ID 2146957973,Telegram-zh_CN 简体中文包..,Telegram zh_CN Simplified Chinese pack … +68,私有,N/A,ID 2268109497,中文导航 中文搜索 中文群组 中文翻译,"Chinese directory, Chinese search, Chinese groups, Chinese translation" +69,未知,N/A,ID 2334140826,TG中文包【汉化】翻译 简体中文包 中文..,"TG Chinese pack [localization] translation, Simplified Chinese pack, Chinese …" +70,未知,N/A,ID 2508630894,Tеlеցrαm🔥 中文包丨汉化丨简体..,Telegram Chinese pack / localization / Simplified … +71,未知,N/A,ID 2522803089,中文包 简体中文包 中文转换包 中文语言..,"Chinese pack, Simplified Chinese pack, Chinese conversion pack, Chinese language …" +72,未知,N/A,ID 2601237857,zh_CN 中文安装 中文翻译 简体中文..,"zh_CN Chinese installation, Chinese translation, Simplified Chinese …" +73,未知,N/A,ID 1183238584,中文翻译包 中文汉化 ch_zn,"Chinese translation pack, Chinese localization ch_zn" +74,未知,N/A,ID 2664539687,zh_ϹN 中文安装 中文翻译 简体中文..,"zh_CN Chinese installation, Chinese translation, Simplified Chinese …" +75,未知,N/A,ID 1624527901,zh_CN 简体中文汉化包 中文汉化 中..,"zh_CN Simplified Chinese localization pack, Chinese localization, Chinese …" +76,频道,46204,ID 1424475877,zh_cη 简体中文包 中文安装包 中文..,"zh_cn Simplified Chinese pack, Chinese installer pack, Chinese …" +77,频道,44585,ID 1689354340,汉语中文翻译,Mandarin Chinese translation +78,频道,34597,ID 1671814308,海象ws/tg/line/fb/zalo..,Walrus ws/tg/line/fb/zalo … +79,频道,33726,ID 1401230741,免费vpn梯子机场翻墙技术,Free VPN ladder airport circumvention technology +80,频道,33645,ID 1646824771,helłoworld官方客服 海外社交软..,helloworld official customer service overseas social … +81,频道,32765,ID 1178337317,白嫖机场节点小火箭翻墙梯子|免费VPN,Free airport nodes Shadowrocket circumvention ladder / free VPN +82,私有,N/A,ID 1606604404,hełloworld官方客服频道 海外社..,helloworld official customer service channel overseas social … +83,未知,N/A,ID 1292723381,tg中文包 江湖中文包 聪聪中文包 飞机..,"TG Chinese pack, Jianghu Chinese pack, Congcong Chinese pack, Telegram …" +84,私有,N/A,ID 2019856458,zh_CN-语言包 翻译包 安装包,"zh_CN language pack, translation pack, installer pack" +85,未知,N/A,ID 2097963587,快连VPN-翻墙加速器-VPN快连-电脑..,"Kuailian VPN circumvention accelerator, VPN Kuailian, computer …" +86,私有,N/A,ID 2255570176,zh_cn 中文安装 简体中文 中文翻译,"zh_cn Chinese installation, Simplified Chinese, Chinese translation" +87,未知,N/A,ID 2364236658,翻译中文 最新版,Chinese translation latest version +88,未知,N/A,ID 2394841482,zh_cn 中文语言 简体中文 中文安装..,"zh_cn Chinese language, Simplified Chinese, Chinese installation …" +89,未知,N/A,ID 2459873292,QᴜickQ官方频道 翻墙加速器🚀,QuickQ official channel circumvention accelerator 🚀 +90,未知,N/A,ID 2655916317,江湖丨中文包丨中文翻译丨简体中文丨中文安..,"Jianghu Chinese pack, Chinese translation, Simplified Chinese, Chinese …" +91,频道,30347,ID 2181323289,zh_CN 简体中文 中文包 中文 简体..,"zh_CN Simplified Chinese, Chinese pack, Chinese, Simplified …" +92,频道,30332,ID 2190065351,zh_CN 简体中文 中文包 中文 简体..,"zh_CN Simplified Chinese, Chinese pack, Chinese, Simplified …" +93,频道,30324,ID 2158084236,zh_CN 简体中文 中文包 中文 简体..,"zh_CN Simplified Chinese, Chinese pack, Chinese, Simplified …" +94,频道,27661,ID 1976367427,helⅼowοrłd官方客服频道 heⅼ..,helloworld official customer service channel hel… +95,频道,27564,ID 1394985741,象征 ... JC▸ ⌜ 翻译 ⌟ ──..,Symbolic … JC▸ Translation … +96,频道,26109,ID 1982792091,helloworld官方客服频道 海外社..,helloworld official customer service channel overseas social … +97,频道,25680,ID 1963495599,TranWorⅼd翻译软件唯一客服(唯一..,TranWorld translation software only customer service (only … +98,频道,23541,ID 3128265746,逆风翻盘局【9Y.COM】,Turn the tides bureau [9Y.COM] +99,频道,23474,ID 1178940393,破解VPN机场节点翻墙加速器小火箭|免费..,Cracked VPN airport nodes circumvention accelerator Shadowrocket / free … +100,频道,N/A,ID 1388012744,免费翻墙VPN机场软件群,Free VPN airport software group