#!/usr/bin/env python3 """ Telegram Bot V3 - 完整重构版 特性: 1. 智能AI引导 - 用户说需求,AI分析给出按钮选项 2. 完整的bytes处理 - 所有callback_data统一用hex存储 3. 返回重选功能 - 搜索结果可返回重新选择 4. 缓存与按需翻页 - 兼顾用户体验 5. 增强日志系统 - 不删档完整记录 """ import asyncio import logging import time import os import httpx import anthropic import json import re from typing import Dict, Optional, List from datetime import datetime, timedelta # Pyrogram from pyrogram import Client as PyrogramClient, filters from pyrogram.types import Message as PyrogramMessage from pyrogram.raw.functions.messages import GetBotCallbackAnswer # Telegram Bot from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup from telegram.ext import Application, CommandHandler, MessageHandler, CallbackQueryHandler, filters as tg_filters, ContextTypes from telegram.request import HTTPXRequest # 数据库 import sys sys.path.insert(0, "/home/atai/bot_data") from database import CacheDatabase # 增强日志 from enhanced_logger import EnhancedLogger # ==================== 配置 ==================== API_ID = 24660516 API_HASH = "eae564578880a59c9963916ff1bbbd3a" SESSION_NAME = "user_session" BOT_TOKEN = os.environ.get("BOT_TOKEN", "8426529617:AAHAxzohSMFBAxInzbAVJsZfkB5bHnOyFC4") TARGET_BOT = "@openaiw_bot" ADMIN_ID = 7363537082 # 初始化日志 enhanced_log = EnhancedLogger("bot_v3", log_dir="./logs") logger = enhanced_log.get_logger() logger.info("🚀 Bot V3 启动中...") # 初始化Claude try: claude_client = anthropic.Anthropic( api_key=os.environ.get('ANTHROPIC_AUTH_TOKEN'), base_url=os.environ.get('ANTHROPIC_BASE_URL', 'https://api.anthropic.com') ) logger.info("✅ Claude API已初始化") except Exception as e: logger.error(f"❌ Claude API初始化失败: {e}") claude_client = None # ==================== 工具函数 ==================== def bytes_to_hex(data) -> Optional[str]: """bytes转hex字符串 - 用于JSON存储""" if data is None: return None if isinstance(data, bytes): return data.hex() return str(data) def hex_to_bytes(hex_str): """hex字符串转bytes - 用于恢复callback""" if hex_str is None: return None if isinstance(hex_str, bytes): return hex_str try: return bytes.fromhex(hex_str) except (ValueError, AttributeError): return hex_str.encode('utf-8') if isinstance(hex_str, str) else hex_str # ==================== 会话管理器 ==================== class SessionManager: """用户会话管理""" def __init__(self): self.sessions: Dict[int, dict] = {} self.timeout = timedelta(minutes=30) def create(self, user_id: int, query: str) -> dict: """创建会话""" session = { "user_id": user_id, "stage": "initial", "query": query, "analysis": None, "selected": None, "can_back": False, "created_at": datetime.now() } self.sessions[user_id] = session logger.info(f"[会话] 创建: user={user_id}") return session def get(self, user_id: int) -> Optional[dict]: """获取会话""" session = self.sessions.get(user_id) if session and datetime.now() - session['created_at'] > self.timeout: del self.sessions[user_id] return None return session def update(self, user_id: int, **kwargs): """更新会话""" session = self.get(user_id) if session: session.update(kwargs) def clear(self, user_id: int): """清除会话""" if user_id in self.sessions: del self.sessions[user_id] # ==================== AI分析器 ==================== class AIAnalyzer: """AI意图分析""" def __init__(self, client): self.client = client self.model = "claude-sonnet-4-20250514" async def analyze(self, user_input: str) -> dict: """分析用户意图 - 生成30个相关关键词""" if not self.client: return self._fallback(user_input) prompt = f"""分析Telegram搜索需求,生成30个相关的关键词。 用户输入: "{user_input}" 要求: 1. 生成30个与用户输入相关的关键词 2. 关键词要具体、可搜索 3. 涵盖不同角度和相关话题 4. 按相关性排序(最相关的在前) 返回JSON格式: {{ "explanation": "1句话说明用户想要什么", "keywords": [ "关键词1", "关键词2", ...共30个 ] }} 示例: 用户: "德州" 返回: {{"explanation": "德州扑克相关", "keywords": ["德州扑克", "德州扑克俱乐部", "德州扑克教学", ...]}}""" try: response = self.client.messages.create( model=self.model, max_tokens=1200, messages=[{"role": "user", "content": prompt}] ) text = response.content[0].text.strip() # 提取JSON match = re.search(r'```json\s*(.*?)\s*```', text, re.DOTALL) if match: text = match.group(1) # 尝试找到{} match = re.search(r'\{.*\}', text, re.DOTALL) if match: text = match.group(0) result = json.loads(text) # 验证 if 'keywords' in result and isinstance(result['keywords'], list): logger.info(f"[AI] 分析成功: {len(result['keywords'])}个关键词") return result else: raise ValueError("格式错误") except Exception as e: logger.error(f"[AI] 分析失败: {e}") return self._fallback(user_input) def _fallback(self, user_input: str) -> dict: """Fallback - AI失败时生成基础关键词""" suffixes = [ "", "群", "群聊", "交流群", "交流群组", "俱乐部", "社群", "社区", "论坛", "讨论组", "频道", "频道推荐", "资源", "资源分享", "教程", "教程分享", "学习", "学习群", "干货", "工具", "工具包", "软件", "APP", "推荐", "最新", "官方", "中文", "免费", "精品", "入门" ] keywords = [] seen = set() for suffix in suffixes: keyword = f"{user_input}{suffix}".strip() lower = keyword.lower() if keyword and lower not in seen: keywords.append(keyword) seen.add(lower) if len(keywords) >= 30: break return { "explanation": f"为「{user_input}」生成的关键词", "keywords": keywords[:30] } class TelegramBotV3: """主Bot类""" def __init__(self): self.sessions = SessionManager() self.ai = AIAnalyzer(claude_client) self.cache_db = None self.pyrogram_client = None self.app = None self.target_bot_id = None # Callback映射 self.callback_map = {} # Pyrogram消息映射 self.pyro_to_tg = {} self.tg_to_pyro = {} # 搜索会话 self.search_sessions = {} async def setup_pyrogram(self) -> bool: """设置Pyrogram客户端""" try: proxy = {"scheme": "socks5", "hostname": "127.0.0.1", "port": 1080} self.pyrogram_client = PyrogramClient( SESSION_NAME, API_ID, API_HASH, workdir="/home/atai/telegram-bot", proxy=proxy ) await self.pyrogram_client.start() # 获取目标bot target = await self.pyrogram_client.get_users(TARGET_BOT) self.target_bot_id = target.id # 设置消息处理 @self.pyrogram_client.on_message(filters.user(self.target_bot_id)) async def handle_bot_message(client, message): await self.handle_search_response(message) logger.info(f"✅ Pyrogram已启动: {TARGET_BOT}") return True except Exception as e: logger.error(f"❌ Pyrogram失败: {e}") return False async def initialize(self) -> bool: """初始化""" try: logger.info("正在初始化...") # 初始化Pyrogram if not await self.setup_pyrogram(): return False # 初始化缓存 try: self.cache_db = CacheDatabase("/home/atai/bot_data/cache.db") logger.info("✅ 缓存已加载") except Exception as e: logger.warning(f"缓存加载失败: {e}") # 初始化Telegram Bot builder = Application.builder().token(BOT_TOKEN) if os.environ.get('ALL_PROXY'): proxy_url = os.environ.get('ALL_PROXY') request = HTTPXRequest( proxy=proxy_url, connect_timeout=30.0, read_timeout=30.0 ) builder = builder.request(request) self.app = builder.build() # 注册处理器 self.app.add_handler(CommandHandler("start", self.handle_start)) self.app.add_handler(MessageHandler(tg_filters.TEXT & ~tg_filters.COMMAND, self.handle_message)) self.app.add_handler(CallbackQueryHandler(self.handle_callback)) logger.info("✅ 初始化完成") return True except Exception as e: logger.error(f"❌ 初始化失败: {e}") return False async def handle_start(self, update: Update, context: ContextTypes.DEFAULT_TYPE): """处理/start""" user = update.effective_user welcome = ( f"👋 您好 {user.first_name}!\n\n" "我是智能搜索助手,可以帮您找到Telegram群组和频道。\n\n" "💬 直接告诉我您想找什么,我会为您准备搜索方案!\n\n" "例如:\n" "• 我想找德州扑克群\n" "• 寻找AI工具讨论\n" "• 科技资讯频道" ) keyboard = [ [InlineKeyboardButton("🔥 浏览热门分类", callback_data="cmd_topchat")], [InlineKeyboardButton("📖 使用帮助", callback_data="show_help")] ] await update.message.reply_text(welcome, reply_markup=InlineKeyboardMarkup(keyboard)) # 通知管理员 if user.id != ADMIN_ID: try: await context.bot.send_message( chat_id=ADMIN_ID, text=f"🆕 新用户: {user.first_name} (@{user.username or '无'}) - {user.id}" ) except: pass async def handle_message(self, update: Update, context: ContextTypes.DEFAULT_TYPE): """处理用户消息 - 不再提供关键词推荐""" user = update.effective_user raw_text = update.message.text or "" text = raw_text.strip() if not text: await update.message.reply_text("请发送要搜索的内容,例如“德州扑克群”。") return logger.info(f"[用户 {user.id}] 输入: {text}") self.sessions.create(user.id, text) self.sessions.update( user.id, selected_keyword=text, stage="commands", can_back=False, analysis=None ) buttons = [ [InlineKeyboardButton("🔍 按名称搜索 (/search)", callback_data=f"cmd_{user.id}_search")], [InlineKeyboardButton("💬 按内容搜索 (/text)", callback_data=f"cmd_{user.id}_text")], [InlineKeyboardButton("👤 按用户搜索 (/human)", callback_data=f"cmd_{user.id}_human")], [InlineKeyboardButton("📊 查看信息 (/info)", callback_data=f"cmd_{user.id}_info")], ] reply_text = f"收到「{text}」\n\n请选择需要使用的搜索方式,或直接输入具体命令。" await update.message.reply_text( reply_text, reply_markup=InlineKeyboardMarkup(buttons) ) async def handle_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE): """处理按钮点击""" query = update.callback_query data = query.data user = query.from_user logger.info(f"[回调] user={user.id}, data={data}") await query.answer() # 【第二级】处理指令选择 - 执行搜索 if data.startswith("cmd_"): await self.handle_command_click(query) return # 返回搜索方式 if data == "back_to_keywords": await self.handle_back_to_keywords(query) return # 手动输入 if data == "manual_input": await query.message.edit_text( "✍️ 请直接发送命令:\n\n" "• /search 关键词\n" "• /text 关键词\n" "• /human 关键词\n" "• /topchat" ) return # 快捷搜索 if data.startswith("quick_"): parts = data.split("_", 2) if len(parts) == 3: cmd_type = parts[1] keyword = parts[2] await query.message.edit_text(f"🔍 搜索中: {keyword}\n请稍候...") try: await self.pyrogram_client.send_message( self.target_bot_id, f"/{cmd_type} {keyword}" ) self.search_sessions[user.id] = { 'chat_id': query.message.chat_id, 'wait_msg_id': query.message.message_id, 'command': f"/{cmd_type}", 'keyword': keyword, 'can_back': False } except Exception as e: logger.error(f"[搜索] 失败: {e}") await query.message.edit_text("❌ 搜索失败") return # 翻页callback if data.startswith("cb_"): await self.handle_pagination(query, data) return logger.warning(f"[回调] 未知: {data}") async def handle_back_to_keywords(self, query): """返回搜索选项""" user = query.from_user session = self.sessions.get(user.id) if not session: await query.message.edit_text("❌ 会话已过期,请重新输入") return keyword = session.get('selected_keyword') or session.get('query') or "" buttons = [ [InlineKeyboardButton("🔍 按名称搜索 (/search)", callback_data=f"cmd_{user.id}_search")], [InlineKeyboardButton("💬 按内容搜索 (/text)", callback_data=f"cmd_{user.id}_text")], [InlineKeyboardButton("👤 按用户搜索 (/human)", callback_data=f"cmd_{user.id}_human")], [InlineKeyboardButton("📊 查看信息 (/info)", callback_data=f"cmd_{user.id}_info")], ] reply_text = f"当前搜索词:{keyword or '(未指定)'}\n\n请选择需要使用的搜索方式,或直接输入具体命令。" await query.message.edit_text( reply_text, reply_markup=InlineKeyboardMarkup(buttons) ) session['stage'] = 'commands' session['can_back'] = False self.sessions.update(user.id, stage='commands', can_back=False) logger.info(f"[用户 {user.id}] 返回搜索方式") async def handle_command_click(self, query): """【第二级】指令点击 - 执行搜索""" user = query.from_user data = query.data # 解析: cmd_userid_command parts = data.split("_") if len(parts) < 3: return command = parts[2] # search/text/human/info # 获取会话 session = self.sessions.get(user.id) if not session or not session.get('selected_keyword'): await query.message.edit_text("❌ 会话已过期,请重新输入") return keyword = session['selected_keyword'] # 构建完整命令 full_cmd = f"/{command} {keyword}" logger.info(f"[用户 {user.id}] 执行: {full_cmd}") # 先检查缓存 # 缓存未命中,显示搜索中 await query.message.edit_text( f"✅ 执行指令: {full_cmd}\n\n🔍 正在搜索,请稍候...", parse_mode='HTML' ) # 执行搜索 try: await self.pyrogram_client.send_message(self.target_bot_id, full_cmd) self.search_sessions[user.id] = { 'chat_id': query.message.chat_id, 'wait_msg_id': query.message.message_id, 'command': f"/{command}", 'keyword': keyword, 'can_back': True, 'last_page': 1, 'source_msg_id': None, 'timestamp': datetime.now() } logger.info(f"[搜索] 已转发: {full_cmd}") except Exception as e: logger.error(f"[搜索] 失败: {e}") await query.message.edit_text("❌ 搜索失败,请重试") async def handle_search_response(self, message: PyrogramMessage): """处理服务商返回的搜索结果""" try: for user_id, session in list(self.search_sessions.items()): if datetime.now() - session.get('timestamp', datetime.now()) > timedelta(seconds=10): continue try: text = message.text.html except Exception: text = message.text or message.caption or "" keyboard = self.convert_keyboard(message) if session.get('can_back') and keyboard: buttons = list(keyboard.inline_keyboard) buttons.append([InlineKeyboardButton("🔙 返回搜索方式", callback_data="back_to_keywords")]) keyboard = InlineKeyboardMarkup(buttons) updated_message = None try: updated_message = await self.app.bot.edit_message_text( chat_id=session['chat_id'], message_id=session['wait_msg_id'], text=text[:4000], reply_markup=keyboard, parse_mode='HTML' ) except Exception as edit_error: logger.warning(f"[搜索响应] 编辑消息失败: {edit_error}") try: updated_message = await self.app.bot.send_message( chat_id=session['chat_id'], text=text[:4000], reply_markup=keyboard, parse_mode='HTML' ) except Exception as send_error: logger.error(f"[搜索响应] 发送消息失败: {send_error}") continue session['message_id'] = updated_message.message_id session['chat_id'] = updated_message.chat_id session['wait_msg_id'] = updated_message.message_id session['source_msg_id'] = message.id session['last_page'] = 1 session['can_back'] = True self.pyro_to_tg[message.id] = updated_message.message_id self.tg_to_pyro[updated_message.message_id] = message.id if self.cache_db and session.get('keyword'): buttons_data = self.extract_buttons(message) self.cache_db.save_cache( session['command'], session['keyword'], 1, text, buttons_data ) session['timestamp'] = datetime.now() self.search_sessions[user_id] = session break except Exception as e: logger.error(f"[搜索响应] 失败: {e}") async def fetch_updated_message(self, message_id: int, attempts: int = 6, delay: float = 0.7): for _ in range(attempts): try: msg = await self.pyrogram_client.get_messages(self.target_bot_id, message_id) except Exception as exc: logger.error(f"[翻页] 获取消息失败: {exc}") msg = None if msg and (msg.reply_markup or msg.text or msg.caption): return msg await asyncio.sleep(delay) return None async def handle_pagination(self, query, data): """处理翻页按钮""" user = query.from_user if data not in self.callback_map: await query.answer('按钮已过期', show_alert=False) return orig_msg_id, orig_callback = self.callback_map[data] session = self.search_sessions.get(user.id) if not session: await query.answer('会话已过期', show_alert=True) return if isinstance(orig_callback, bytes): callback_bytes = orig_callback callback_str = orig_callback.decode('utf-8', 'ignore') else: callback_str = str(orig_callback) callback_bytes = hex_to_bytes(callback_str) if orig_msg_id == 0 and session.get('source_msg_id'): orig_msg_id = session['source_msg_id'] page = None match = re.search(r"page_(\d+)", callback_str or "") if match: page = int(match.group(1)) elif session.get('last_page'): page = session['last_page'] + 1 cached = None if self.cache_db and session.get('keyword') and page: cached = self.cache_db.get_cache(session['command'], session['keyword'], page) await query.answer('正在加载...', show_alert=False) try: await self.pyrogram_client.invoke( GetBotCallbackAnswer( peer=await self.pyrogram_client.resolve_peer(self.target_bot_id), msg_id=orig_msg_id, data=callback_bytes ) ) except Exception as e: logger.error(f"[翻页] 回调失败: {e}") if cached: await self._apply_cached_page(query, session, cached, page) else: await query.message.edit_text("❌ 翻页失败") return updated_msg = await self.fetch_updated_message(orig_msg_id) if not updated_msg: if cached: await self._apply_cached_page(query, session, cached, page) return await query.message.edit_text("❌ 未获取到新内容,请稍后重试") return try: text = updated_msg.text.html except Exception: text = updated_msg.text or updated_msg.caption or "" keyboard = self.convert_keyboard(updated_msg) if session.get('can_back') and keyboard: buttons = list(keyboard.inline_keyboard) buttons.append([InlineKeyboardButton("🔙 返回搜索方式", callback_data="back_to_keywords")]) keyboard = InlineKeyboardMarkup(buttons) await query.message.edit_text( text[:4000], reply_markup=keyboard, parse_mode='HTML' ) if self.cache_db and session.get('keyword') and page: buttons_data = self.extract_buttons(updated_msg) self.cache_db.save_cache( session['command'], session['keyword'], page, text, buttons_data ) if page: session['last_page'] = page session['source_msg_id'] = updated_msg.id session['timestamp'] = datetime.now() self.search_sessions[user.id] = session async def _apply_cached_page(self, query, session, cached, page): keyboard = self.rebuild_keyboard(cached.get('buttons', []), session.get('can_back', False)) await query.message.edit_text( cached['text'][:4000], reply_markup=keyboard, parse_mode='HTML' ) if page: session['last_page'] = page session['timestamp'] = datetime.now() self.search_sessions[query.from_user.id] = session def convert_keyboard(self, message: PyrogramMessage): if not message.reply_markup or not message.reply_markup.inline_keyboard: return None try: buttons = [] for row in message.reply_markup.inline_keyboard: button_row = [] for btn in row: if btn.url: button_row.append(InlineKeyboardButton(text=btn.text, url=btn.url)) elif btn.callback_data: callback_id = f"cb_{time.time():.0f}_{len(self.callback_map)}" self.callback_map[callback_id] = (message.id, btn.callback_data) button_row.append(InlineKeyboardButton(text=btn.text, callback_data=callback_id[:64])) else: button_row.append(InlineKeyboardButton(text=btn.text, callback_data="unknown")) if button_row: buttons.append(button_row) return InlineKeyboardMarkup(buttons) if buttons else None except Exception as e: logger.error(f"[键盘转换] 失败: {e}") return None def extract_buttons(self, message: PyrogramMessage) -> list: if not message.reply_markup or not message.reply_markup.inline_keyboard: return [] buttons = [] for row in message.reply_markup.inline_keyboard: for btn in row: btn_data = {"text": btn.text, "msg_id": message.id} if btn.url: btn_data["url"] = btn.url if btn.callback_data: btn_data["callback_data"] = bytes_to_hex(btn.callback_data) buttons.append(btn_data) return buttons def rebuild_keyboard(self, buttons_data: list, can_back: bool = False): if not buttons_data: if can_back: return InlineKeyboardMarkup([[InlineKeyboardButton("🔙 返回搜索方式", callback_data="back_to_keywords")]]) return None session_msg_id = 0 for btn_data in buttons_data: if btn_data.get('msg_id'): session_msg_id = btn_data['msg_id'] break buttons = [] current_row = [] for btn_data in buttons_data: btn = None if btn_data.get('url'): btn = InlineKeyboardButton(text=btn_data['text'], url=btn_data['url']) elif btn_data.get('callback_data'): callback_id = f"cb_{time.time():.0f}_{len(self.callback_map)}" callback_bytes = hex_to_bytes(btn_data['callback_data']) source_msg_id = btn_data.get('msg_id') or session_msg_id self.callback_map[callback_id] = (source_msg_id, callback_bytes) btn = InlineKeyboardButton(text=btn_data['text'], callback_data=callback_id[:64]) if not btn: continue current_row.append(btn) if len(current_row) >= 4: buttons.append(current_row) current_row = [] if current_row: buttons.append(current_row) if can_back: buttons.append([InlineKeyboardButton("🔙 返回搜索方式", callback_data="back_to_keywords")]) return InlineKeyboardMarkup(buttons) if buttons else None async def run(self): """运行""" try: await self.app.initialize() await self.app.start() await self.app.updater.start_polling(drop_pending_updates=True) logger.info("=" * 60) logger.info("✅ Bot V3 已启动") logger.info("=" * 60) await asyncio.Event().wait() except KeyboardInterrupt: logger.info("收到停止信号") finally: await self.cleanup() async def cleanup(self): """清理""" logger.info("正在清理...") if self.app: await self.app.updater.stop() await self.app.stop() await self.app.shutdown() if self.pyrogram_client: await self.pyrogram_client.stop() logger.info("✅ 清理完成") async def main(): """主函数""" bot = TelegramBotV3() if await bot.initialize(): await bot.run() else: logger.error("初始化失败,退出") if __name__ == "__main__": asyncio.run(main())