import os import asyncio import json import logging import re # For hash checking and Markdown escaping import base64 # For encoding path parts in callback data if needed (currently simplified) import time # NEW: For cleanup task duration calculation from datetime import datetime, timedelta # NEW: Import datetime and timedelta for scheduling from collections import deque # NEW: For cleanup task folder traversal # MODIFIED: Import Bot, CallbackQuery for typing and context from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup, Bot, CallbackQuery # MODIFIED: Import constants explicitly for parse_mode from telegram.constants import ParseMode from telegram.error import BadRequest, TelegramError # Import BadRequest and base TelegramError from telegram.ext import ( Application, CommandHandler, ConversationHandler, MessageHandler, CallbackQueryHandler, CallbackContext, ContextTypes, filters, ) import httpx from pikpakapi import PikPakApi # NEW: Import scheduler library from apscheduler.schedulers.asyncio import AsyncIOScheduler from apscheduler.triggers.interval import IntervalTrigger from typing import Union, Any, Dict, List, Optional, Tuple # Added Tuple from fastapi import ( FastAPI, APIRouter, Depends, Request, Query, Body, Path, Response, HTTPException, status, Request, ) from fastapi.responses import StreamingResponse, HTMLResponse, JSONResponse from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from fastapi.templating import Jinja2Templates from fastapi.middleware.cors import CORSMiddleware from pydantic import BaseModel, Extra, Field # MODIFIED: Import Field # NEW: Configure logging logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') logger = logging.getLogger(__name__) # --- Constants --- ITEMS_PER_PAGE = 10 # NEW: Items per page for TG file browser (Adjust as needed) # SIMPLIFICATION: Define shared command help text COMMAND_HELP_TEXT_TEMPLATE = """ 🚀 *欢迎使用 Thunder X 管理机器人* 🚀 我是由 *{bot_username}* 控制的。 📋 *可用命令:* • 直接发送 `magnet:` 开头的磁力链接 • 直接发送 `http(s)://` 开头的受支持下载链接 • 直接发送 `40位BT Hash` \(例如: `{example_hash}`\) • 直接发送 `share:<分享ID>` \(例如: `share:ABCd123...`\) • 在链接或Hash后添加 `folder_id:<你的文件夹ID>` 来指定保存目录 \(例如: `magnet:... folder_id:xxx`\) • /tasks \- 🚀 查看和管理离线任务 • /files \- 📂 浏览和管理云盘文件 • /shares \- 🔗 查看和管理分享链接 • /quota \- 💾 查看存储空间使用情况 • /emptytrash \- 🗑️ 清空回收站 • /help \- ℹ️ 显示此帮助信息 """ class PostRequest(BaseModel): class Config: extra = Extra.allow class FileRequest(BaseModel): size: int = 100 parent_id: str | None = "" next_page_token: str | None = "" additional_filters: Dict | None = {} class Config: extra = Extra.allow class OfflineRequest(BaseModel): file_url: str = "" # MODIFIED: Make parent_id optional in request body, default handled later parent_id: str | None = Field(default=None, description="目标文件夹ID, 不填则使用环境变量或根目录") name: str | None = "" class Config: extra = Extra.allow security = HTTPBearer() # SECRET_TOKEN = "SECRET_TOKEN" SECRET_TOKEN = os.getenv("SECRET_TOKEN") if SECRET_TOKEN is None: raise ValueError("请在环境变量中设置SECRET_TOKEN,确保安全!") THUNDERX_USERNAME = os.getenv("THUNDERX_USERNAME") if THUNDERX_USERNAME is None: raise ValueError("请在环境变量中设置THUNDERX_USERNAME,用户名【邮箱】用来登陆!") THUNDERX_PASSWORD = os.getenv("THUNDERX_PASSWORD") if THUNDERX_PASSWORD is None: raise ValueError("请在环境变量中设置THUNDERX_PASSWORD,密码用来登陆!") PROXY_URL = os.getenv("PROXY_URL") TG_BOT_TOKEN = os.getenv("TG_BOT_TOKEN") TG_WEBHOOK_URL = os.getenv("TG_WEBHOOK_URL") # NEW: Environment variables for cleanup and default download folder CLEANUP_ENABLED = os.getenv("CLEANUP_ENABLED", "false").lower() == "true" CLEANUP_INTERVAL_HOURS = int(os.getenv("CLEANUP_INTERVAL_HOURS", "48")) # Default: Run every 2 days CLEANUP_SIZE_THRESHOLD_MB = int(os.getenv("CLEANUP_SIZE_THRESHOLD_MB", "50")) # Default: 50MB CLEANUP_TARGET_FOLDER_ID = os.getenv("CLEANUP_TARGET_FOLDER_ID", None) # Default: None (clean root or specific folder based on RECURSIVE) DEFAULT_DOWNLOAD_FOLDER_ID = os.getenv("DEFAULT_DOWNLOAD_FOLDER_ID", "") # Default: Root directory "" # --- NEW: Cleanup Recursion Option --- # Default to False (non-recursive) if not specified. Set to "true" to enable recursion. CLEANUP_RECURSIVE = os.getenv("CLEANUP_RECURSIVE", "false").lower() == "true" logger.info(f"Cleanup recursive mode enabled: {CLEANUP_RECURSIVE}") # ------------------------------------ # --- NEW: Admin Chat ID for Notifications --- TG_ADMIN_CHAT_ID = os.getenv("TG_ADMIN_CHAT_ID") # Used for notifications AND authorization if CLEANUP_ENABLED and not TG_ADMIN_CHAT_ID: logger.warning("Cleanup task is enabled, but TG_ADMIN_CHAT_ID is not set. Completion notifications will NOT be sent.") # ------------------------------------------- # --- Convert Admin ID for Filtering --- ALLOWED_USER_ID: Optional[int] = None if TG_ADMIN_CHAT_ID: try: ALLOWED_USER_ID = int(TG_ADMIN_CHAT_ID) logger.info(f"Telegram Bot usage will be restricted to User ID: {ALLOWED_USER_ID}") except ValueError: logger.error("Invalid TG_ADMIN_CHAT_ID provided. It must be a number. Bot authorization may not work.") ALLOWED_USER_ID = None # Disable filtering if ID is invalid else: # If no admin ID is set, log an error because restriction is expected logger.error("TG_ADMIN_CHAT_ID not set. Telegram Bot will respond to *any* user. User restriction is disabled!") # NEW: Calculate size threshold in bytes CLEANUP_SIZE_THRESHOLD_BYTES = CLEANUP_SIZE_THRESHOLD_MB * 1024 * 1024 # NEW: Scheduler instance scheduler = AsyncIOScheduler() async def verify_token( request: Request, credentials: HTTPAuthorizationCredentials = Depends(security) ): # 验证Bearer格式 if credentials.scheme != "Bearer": raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid authentication scheme", ) # 验证令牌内容 if credentials.credentials != SECRET_TOKEN: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired token" ) def format_bytes(size: Union[int, str, None]) -> str: units = ["B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"] if size is None: return "N/A" try: size_int = int(size) # Try converting string size from API except (ValueError, TypeError): return "N/A" if size_int < 0: return "0 B" unit_index = 0 size_float = float(size_int) while size_float >= 1024 and unit_index < len(units) - 1: size_float /= 1024.0 unit_index += 1 return f"{size_float:.2f} {units[unit_index]}" app = FastAPI() app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) api_router = APIRouter(dependencies=[Depends(verify_token)]) front_router = APIRouter() templates = Jinja2Templates( directory="templates", variable_start_string="{[", variable_end_string="]}" ) async def log_token(THUNDERX_CLIENT, extra_data): logger.info(f"Token refreshed, Extra Data: {extra_data}") # MODIFIED: Use logger THUNDERX_CLIENT = None TG_BOT_APPLICATION: Optional[Application] = None # MODIFIED: Type hint for clarity TG_BASE_URL = "https://api.telegram.org/bot" # --- Helper to escape MarkdownV2 --- def escape_markdown(text: Union[str, int, float, None]) -> str: """Escapes characters reserved in Telegram's MarkdownV2 syntax.""" if text is None: return "" text_str = str(text) # Characters to escape: _ * [ ] ( ) ~ ` > # + - = | { } . ! escape_chars = r'[_*\[\]()~`>#+\-=|{}.!]' return re.sub(escape_chars, r'\\\g<0>', text_str) # --- SIMPLIFICATION: Telegram Error Sending Helper --- async def send_telegram_error_message( target: Union[Update, CallbackQuery], # Can be Update or Query error_text: str, log_message: str, e: Optional[Exception] = None ): """Logs an error and sends a formatted error message to Telegram.""" if e: logger.error(f"{log_message}: {e}", exc_info=True) escaped_error = escape_markdown(str(e)) final_text = f"❌ {error_text}\n错误详情: `{escaped_error}`" else: logger.error(log_message) # Log without exception details if e is None final_text = f"❌ {error_text}" try: if isinstance(target, CallbackQuery): # Try to edit the message the query came from await target.message.edit_text(final_text, parse_mode=ParseMode.MARKDOWN_V2, reply_markup=None) await target.answer(f"❌ {error_text[:50]}...", show_alert=True) # Show alert as well elif isinstance(target, Update) and target.message: # Send as a reply to the original message await target.message.reply_text(final_text, parse_mode=ParseMode.MARKDOWN_V2) else: logger.error(f"Cannot send error message: Invalid target type {type(target)}") except TelegramError as tg_err: # Catch specific Telegram errors during error reporting logger.error(f"Failed to send Telegram error notification! Original error: {log_message}. TG Error: {tg_err}") except Exception as final_err: # Catch any other errors during error reporting logger.error(f"Unexpected error while sending Telegram error notification! Original error: {log_message}. Final Error: {final_err}") # --- SIMPLIFICATION: Cleanup Helper - Deletion --- async def _perform_cleanup_deletion(files_to_delete: List[str], mode: str) -> Tuple[int, str, bool]: """ Performs the deletion of files and returns deleted_count, status_message, error_occurred. Mode should be 'Recursive' or 'Non-Recursive' for logging. """ deleted_count = 0 status_message = "" error_occurred = False error_message_detail = "" if not files_to_delete: status_message = f"扫描完成,未发现需要删除的文件 ({mode}模式)。" return deleted_count, status_message, error_occurred logger.info(f"[{mode}] Attempting to move {len(files_to_delete)} files to trash.") try: result = await THUNDERX_CLIENT.delete_to_trash(files_to_delete) logger.info(f"[{mode}] Deletion task result: {result}") if result and result.get('task_id'): deleted_count = len(files_to_delete) status_message = f"已成功提交 {deleted_count} 个文件的删除任务 ({mode}模式)。" elif result: deleted_count = len(files_to_delete) status_message = f"{deleted_count} 个文件的删除请求已发送,API 返回: {result} ({mode}模式)。" else: status_message = f"为 {len(files_to_delete)} 个文件发送了删除命令,但收到意外的空/假响应 ({mode}模式)。" logger.warning(status_message) error_occurred = True error_message_detail = "API 对删除操作返回了空/假响应。" except Exception as e: logger.error(f"[{mode}] Error during file deletion: {e}", exc_info=True) error_occurred = True error_message_detail = f"文件删除过程中出错: {e}" status_message = f"清理任务在删除阶段失败 ({mode}模式): {error_message_detail}" # Combine status message and error detail if error occurred during deletion if error_occurred and not status_message.startswith("清理任务在删除阶段失败"): status_message = f"删除阶段出错 ({mode}模式): {error_message_detail}" return deleted_count, status_message, error_occurred # --- SIMPLIFICATION: Cleanup Helper - Notification --- async def _send_cleanup_notification( mode: str, error_occurred: bool, deleted_count: int, duration_str: str, folders_scanned: Optional[int], # None for Non-Recursive total_files_scanned: int, target_folder_display: Optional[str], # None for Recursive status_message: str ): """Sends the cleanup task completion notification to Telegram.""" global TG_BOT_APPLICATION, TG_ADMIN_CHAT_ID, CLEANUP_ENABLED if not (TG_BOT_APPLICATION and TG_ADMIN_CHAT_ID): if not TG_ADMIN_CHAT_ID and CLEANUP_ENABLED: logger.warning("Cleanup finished, but notification skipped as TG_ADMIN_CHAT_ID is not set.") elif not TG_BOT_APPLICATION: logger.error("Cannot send cleanup notification: Telegram bot object not found in application.") return # Determine status icon and summary if error_occurred: status_icon = "❌" status_summary = "任务执行过程中遇到错误。" elif deleted_count > 0: status_icon = "✅" status_summary = "任务成功完成,部分文件已清理。" else: status_icon = "ℹ️" status_summary = "任务完成,未发现需要清理的文件。" # Build notification text safe_mode = escape_markdown(mode) notification_text = f"🧹 *PikPak 清理报告 \({mode}\)* {status_icon}\n\n" notification_text += f"{status_summary}\n\n" notification_text += f"⏱️ *任务耗时:* `{escape_markdown(duration_str)}`\n" if folders_scanned is not None: # Only show for recursive notification_text += f"📁 *扫描文件夹数:* `{escape_markdown(folders_scanned)}`\n" notification_text += f"📄 *扫描文件总数:* `{escape_markdown(total_files_scanned)}`\n" if deleted_count > 0: notification_text += f"🗑️ *移至回收站:* `{escape_markdown(deleted_count)}`\n" elif not error_occurred: notification_text += f"🗑️ *移至回收站:* `0`\n" if target_folder_display is not None: # Only show for non-recursive notification_text += f"🎯 *目标文件夹:* `{escape_markdown(target_folder_display)}`\n" safe_status_message = escape_markdown(status_message) if error_occurred: notification_text += f"ℹ️ *错误详情:* `{safe_status_message}`" else: notification_text += f"ℹ️ *任务详情:* `{safe_status_message}`" # Send the message try: if TG_BOT_APPLICATION.bot: await TG_BOT_APPLICATION.bot.send_message( chat_id=TG_ADMIN_CHAT_ID, text=notification_text, parse_mode=ParseMode.MARKDOWN_V2 ) logger.info(f"Sent {mode} cleanup completion notification to chat ID {TG_ADMIN_CHAT_ID}.") else: logger.error("Cannot send cleanup notification: Telegram bot object not found in application.") except BadRequest as e: logger.error(f"Failed to send Telegram notification (BadRequest) for {mode} cleanup: {e}\nFinal Text:\n{notification_text}", exc_info=True) except Exception as e: logger.error(f"Failed to send Telegram notification for {mode} cleanup task: {e}", exc_info=True) # --- Main Cleanup Dispatcher Function --- async def cleanup_small_files(): global THUNDERX_CLIENT, CLEANUP_RECURSIVE if not THUNDERX_CLIENT: logger.error("Cleanup task dispatch skipped: PikPak client not initialized.") return if CLEANUP_RECURSIVE: logger.info("Dispatching cleanup task to: Recursive Mode") await cleanup_small_files_recursive() else: logger.info("Dispatching cleanup task to: Non-Recursive Mode") await cleanup_small_files_non_recursive() # --- End of Main Cleanup Dispatcher Function --- # --- Recursive Cleanup Function --- async def cleanup_small_files_recursive(): global THUNDERX_CLIENT, CLEANUP_SIZE_THRESHOLD_BYTES, CLEANUP_TARGET_FOLDER_ID if not THUNDERX_CLIENT: logger.error("Recursive Cleanup task skipped: PikPak client not initialized.") return start_time = time.monotonic() mode = "递归" # For helpers logger.info(f"Starting small file cleanup task (Recursive). Threshold: {CLEANUP_SIZE_THRESHOLD_MB}MB. Target Folder ID: {CLEANUP_TARGET_FOLDER_ID or 'Entire Drive'}") files_to_delete = [] deleted_count = 0 total_files_scanned = 0 folders_scanned = 0 error_occurred = False error_message_detail = "" # Store specific error message final_status_message = "递归清理任务已启动。" # Overall status folders_to_scan = deque() start_folder_id = CLEANUP_TARGET_FOLDER_ID if CLEANUP_TARGET_FOLDER_ID else "" folders_to_scan.append(start_folder_id) visited_folders = set() try: # --- Scanning Phase --- while folders_to_scan: current_folder_id = folders_to_scan.popleft() if current_folder_id in visited_folders: continue visited_folders.add(current_folder_id) folders_scanned += 1 logger.info(f"[Recursive] Scanning folder ID: '{current_folder_id}' (Root if empty). Folder #{folders_scanned}") next_page_token = None page_limit = 100 error_in_folder = False # Track error within this folder's pagination while True: try: file_list = await THUNDERX_CLIENT.file_list( size=page_limit, parent_id=current_folder_id, next_page_token=next_page_token, additional_filters={"trashed": {"eq": False}, "phase": {"eq": "PHASE_TYPE_COMPLETE"}} ) if not file_list or 'files' not in file_list or file_list['files'] is None: break for item in file_list['files']: # ... (logic to identify small files and folders - unchanged) ... item_kind = item.get('kind') item_id = item.get('id') item_name = item.get('name', 'N/A') if item_kind == 'drive#file': total_files_scanned += 1 file_size_str = item.get('size') if file_size_str is not None: try: file_size = int(file_size_str) if 0 < file_size < CLEANUP_SIZE_THRESHOLD_BYTES: files_to_delete.append(item_id) except (ValueError, TypeError): logger.warning(f"[Recursive] Could not parse size for file: {item_name} (ID: {item_id}, Size: {file_size_str})") elif item_kind == 'drive#folder': if item_id and item_id not in visited_folders: folders_to_scan.append(item_id) next_page_token = file_list.get('next_page_token') if not next_page_token: break await asyncio.sleep(0.5) except Exception as e: logger.error(f"[Recursive] Error fetching file list page for folder '{current_folder_id}': {e}", exc_info=True) error_occurred = True # Mark overall error error_in_folder = True error_message_detail = f"获取文件夹 {current_folder_id} 列表时出错: {e}" final_status_message = f"清理任务因扫描错误结束 ({mode}模式): {error_message_detail}" break # Stop pagination for this folder if error_in_folder: logger.warning(f"[Recursive] Skipping further scan in folder '{current_folder_id}' due to previous error.") # Optionally: break # Stop entire scan on first folder error? Current logic continues. await asyncio.sleep(1) # Delay between folders # --- Deletion Phase (using helper) --- # Only perform deletion if no scanning errors occurred if not error_occurred: deleted_count, status_msg_delete, error_delete = await _perform_cleanup_deletion(files_to_delete, mode) final_status_message = status_msg_delete # Update overall status if error_delete: error_occurred = True # Mark overall error if deletion failed except Exception as e: logger.error(f"An unexpected error occurred during the recursive cleanup process: {e}", exc_info=True) error_occurred = True error_message_detail = f"发生意外错误: {e}" final_status_message = f"清理任务因意外错误失败 ({mode}模式): {error_message_detail}" finally: # --- Send Notification (using helper) --- end_time = time.monotonic() duration_seconds = end_time - start_time duration_str = f"{duration_seconds:.2f} 秒" logger.info(f"Recursive small file cleanup task finished in {duration_seconds:.4f} seconds. Result: {final_status_message}") await _send_cleanup_notification( mode=mode, error_occurred=error_occurred, deleted_count=deleted_count, duration_str=duration_str, folders_scanned=folders_scanned, # Pass folder count total_files_scanned=total_files_scanned, target_folder_display=None, # Not applicable for recursive report summary status_message=final_status_message ) # --- End of Recursive Cleanup Function --- # --- Non-Recursive Cleanup Function --- async def cleanup_small_files_non_recursive(): global THUNDERX_CLIENT, CLEANUP_SIZE_THRESHOLD_BYTES, CLEANUP_TARGET_FOLDER_ID if not THUNDERX_CLIENT: logger.error("Non-Recursive Cleanup task skipped: PikPak client not initialized.") return start_time = time.monotonic() mode = "非递归" # For helpers target_folder_id_actual = CLEANUP_TARGET_FOLDER_ID if CLEANUP_TARGET_FOLDER_ID else "" target_folder_display = target_folder_id_actual or 'Root' # For logging/display logger.info(f"Starting small file cleanup task (Non-Recursive). Threshold: {CLEANUP_SIZE_THRESHOLD_MB}MB. Target Folder ID: {target_folder_display}") files_to_delete = [] deleted_count = 0 total_files_scanned = 0 error_occurred = False error_message_detail = "" final_status_message = "非递归清理任务已启动。" next_page_token = None page_limit = 100 try: # --- Scanning Phase --- logger.info(f"[Non-Recursive] Scanning only folder ID: '{target_folder_display}'") while True: try: file_list = await THUNDERX_CLIENT.file_list( size=page_limit, parent_id=target_folder_id_actual, next_page_token=next_page_token, additional_filters={"trashed": {"eq": False}, "phase": {"eq": "PHASE_TYPE_COMPLETE"}} ) if not file_list or 'files' not in file_list or file_list['files'] is None: logger.info("[Non-Recursive] No more files found or reached end.") break for file in file_list['files']: # ... (logic to identify small files - unchanged) ... if file.get('kind') == 'drive#file': total_files_scanned += 1 file_id = file.get('id') file_name = file.get('name', 'N/A') file_size_str = file.get('size') if file_size_str is not None: try: file_size = int(file_size_str) if 0 < file_size < CLEANUP_SIZE_THRESHOLD_BYTES: files_to_delete.append(file_id) except (ValueError, TypeError): logger.warning(f"[Non-Recursive] Could not parse size for file: {file_name} (ID: {file_id}, Size: {file_size_str})") next_page_token = file_list.get('next_page_token') if not next_page_token: logger.info("[Non-Recursive] Reached end of file list for target folder.") break await asyncio.sleep(1) except Exception as e: logger.error(f"Error fetching file list page during non-recursive cleanup: {e}", exc_info=True) error_occurred = True error_message_detail = f"获取文件列表时出错: {e}" final_status_message = f"清理任务因扫描错误结束 ({mode}模式): {error_message_detail}" break # Stop cleanup on error # --- Deletion Phase (using helper) --- if not error_occurred: deleted_count, status_msg_delete, error_delete = await _perform_cleanup_deletion(files_to_delete, mode) final_status_message = status_msg_delete if error_delete: error_occurred = True except Exception as e: logger.error(f"An unexpected error occurred during the non-recursive cleanup process: {e}", exc_info=True) error_occurred = True error_message_detail = f"发生意外错误: {e}" final_status_message = f"清理任务因意外错误失败 ({mode}模式): {error_message_detail}" finally: # --- Send Notification (using helper) --- end_time = time.monotonic() duration_seconds = end_time - start_time duration_str = f"{duration_seconds:.2f} 秒" logger.info(f"Non-Recursive small file cleanup task finished in {duration_seconds:.4f} seconds. Result: {final_status_message}") await _send_cleanup_notification( mode=mode, error_occurred=error_occurred, deleted_count=deleted_count, duration_str=duration_str, folders_scanned=None, # Not applicable for non-recursive total_files_scanned=total_files_scanned, target_folder_display=target_folder_display, # Pass target folder status_message=final_status_message ) # --- End of Non-Recursive Cleanup Function --- ###################TG机器人功能区################### # --- Authorization Check Function (Helper) --- async def check_callback_authorization(query: CallbackQuery, context: CallbackContext) -> bool: """Checks if the callback query user is the allowed admin.""" global ALLOWED_USER_ID if ALLOWED_USER_ID is None: logger.error("Callback authorization check failed: ALLOWED_USER_ID is not configured.") try: await query.answer(escape_markdown("❌ 机器人未配置授权用户,操作拒绝。"), show_alert=True) except Exception as e: logger.error(f"Error sending unauthorized answer (no admin ID): {e}") return False if query.from_user.id != ALLOWED_USER_ID: logger.warning(f"Unauthorized callback query attempt by user ID: {query.from_user.id}") try: await query.answer(escape_markdown("❌ 您无权使用此按钮。"), show_alert=True) except Exception as e: logger.error(f"Error sending unauthorized answer: {e}") return False return True # --- Command Handlers --- async def start(update: Update, context: CallbackContext): # SIMPLIFICATION: Use template text bot_username = escape_markdown(context.bot.username) example_hash = escape_markdown("96451E6F1ADBC8827B43621B74EDB30DF45012D6") # Escape example hash help_text = COMMAND_HELP_TEXT_TEMPLATE.format(bot_username=bot_username, example_hash=example_hash) await update.message.reply_text(help_text.strip(), parse_mode=ParseMode.MARKDOWN_V2) async def help_command(update: Update, context: CallbackContext): # SIMPLIFICATION: Use template text bot_username = escape_markdown(context.bot.username) example_hash = escape_markdown("96451E6F1ADBC8827B43621B74EDB30DF45012D6") # Escape example hash help_text = COMMAND_HELP_TEXT_TEMPLATE.format(bot_username=bot_username, example_hash=example_hash) await update.message.reply_text(help_text.strip(), parse_mode=ParseMode.MARKDOWN_V2) async def quota(update: Update, context: CallbackContext): # Use message reference for potential edit message_to_edit = await update.message.reply_text("⏳ 正在查询空间信息\.\.\.", parse_mode=ParseMode.MARKDOWN_V2) try: quota_info = await THUNDERX_CLIENT.get_quota_info() usage_str = quota_info.get('quota', {}).get('usage', '0') limit_str = quota_info.get('quota', {}).get('limit', '0') expires_at_ts = quota_info.get('expires_at') expires_at_str = "N/A" if expires_at_ts: try: dt_obj = datetime.fromisoformat(expires_at_ts.replace('Z', '+00:00')) expires_at_str = dt_obj.strftime('%Y-%m-%d %H:%M:%S %Z') except: expires_at_str = str(expires_at_ts) usage_formatted = format_bytes(usage_str) limit_bytes = int(limit_str) if limit_str and limit_str.isdigit() else 0 limit_formatted = format_bytes(limit_bytes) if limit_bytes > 0 else "无限" safe_usage = escape_markdown(usage_formatted) safe_limit = escape_markdown(limit_formatted) safe_expires = escape_markdown(expires_at_str) message_text = ( f"💾 *存储空间信息*\n\n" f"📊 *已用空间:* `{safe_usage}`\n" f"📈 *总空间:* `{safe_limit}`\n" f"⏰ *会员到期:* `{safe_expires}`" ) await message_to_edit.edit_text(message_text, parse_mode=ParseMode.MARKDOWN_V2) except Exception as e: # SIMPLIFICATION: Use error helper await send_telegram_error_message( target=update, # Pass Update object here error_text="获取空间信息失败", log_message="Error fetching quota info", e=e ) # Attempt to clean up the "Loading..." message if possible try: await message_to_edit.delete() except: pass async def tg_emptytrash(update: Update, context: CallbackContext): message_to_edit = await update.message.reply_text("⏳ 正在尝试清空回收站\.\.\.", parse_mode=ParseMode.MARKDOWN_V2) try: result = await THUNDERX_CLIENT.emptytrash() if result and result.get("task_id"): safe_task_id = escape_markdown(result['task_id']) success_text = f"✅ 清空回收站任务已创建 \(Task ID: `{safe_task_id}`\)" await message_to_edit.edit_text(success_text, parse_mode=ParseMode.MARKDOWN_V2) else: logger.warning(f"Empty trash command possibly failed. API Response: {result}") warn_text = f"⚠️ 操作可能未成功或无需清空。\nAPI 返回: `{escape_markdown(str(result))}`" await message_to_edit.edit_text(warn_text, parse_mode=ParseMode.MARKDOWN_V2) except Exception as e: # SIMPLIFICATION: Use error helper await send_telegram_error_message( target=update, error_text="执行清空回收站时出错", log_message="Error emptying trash", e=e ) try: await message_to_edit.delete() except: pass # --- Message Handler --- async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE): if not update.message or not update.message.text: return text = update.message.text.strip() file_url = None parent_id = DEFAULT_DOWNLOAD_FOLDER_ID if DEFAULT_DOWNLOAD_FOLDER_ID else "" name = None processed = False message_to_edit = None # Store reference for editing status messages folder_id_match = re.search(r'folder_id:(\S+)', text, re.IGNORECASE) if folder_id_match: parent_id = folder_id_match.group(1) text = re.sub(r'\s*folder_id:\S+', '', text, flags=re.IGNORECASE).strip() logger.info(f"Target folder ID specified: {parent_id}") # --- Share Link Handling --- if text.lower().startswith("share:"): processed = True share_id_match = re.match(r'share:(\S+)', text, re.IGNORECASE) if share_id_match: share_id = share_id_match.group(1) safe_share_id = escape_markdown(share_id) logger.info(f"Processing Share ID: {share_id}") message_to_edit = await update.message.reply_text(f"⏳ 正在尝试转存分享 `{safe_share_id}`\.\.\.", parse_mode=ParseMode.MARKDOWN_V2) try: target_restore_folder = parent_id if folder_id_match else None logger.info(f"Attempting to restore share {share_id} to folder: {target_restore_folder or 'Root'}") result = await THUNDERX_CLIENT.restore(share_id, None, target_restore_folder) if result is not None and result.get('task', {}).get('id'): safe_task_id = escape_markdown(result['task']['id']) success_text = f"✅ 转存分享 `{safe_share_id}` 任务已提交 \(Task ID: `{safe_task_id}`\)。" await message_to_edit.edit_text(success_text, parse_mode=ParseMode.MARKDOWN_V2) elif result is not None: warn_text = f"✅ 转存分享 `{safe_share_id}` 任务已提交,但未获取到任务ID。\nAPI响应: `{escape_markdown(str(result)[:100])}`" await message_to_edit.edit_text(warn_text, parse_mode=ParseMode.MARKDOWN_V2) else: fail_text = f"❌ 转存分享 `{safe_share_id}` 失败。\nAPI响应: `{escape_markdown(str(result)[:100])}`" await message_to_edit.edit_text(fail_text, parse_mode=ParseMode.MARKDOWN_V2) except Exception as e: # SIMPLIFICATION: Use error helper await send_telegram_error_message( target=update, # Original update obj error_text=f"转存分享 `{safe_share_id}` 时出错", log_message=f"Error restoring share {share_id}", e=e ) try: await message_to_edit.delete() # Clean up loading message except: pass else: await update.message.reply_text(f"❌ 无效的 `share:` 格式。请使用 `share:<分享ID>`。") return # Handled share # --- Link/Hash Handling --- if text.lower().startswith(("magnet:", "http:", "https:", "ftp:", "ftps:", "ed2k:")): file_url = text logger.info(f"Processing Link: {file_url[:60]}... Target Folder: {parent_id or '默认(Root)'}") processed = True elif re.fullmatch(r'[a-fA-F0-9]{40}', text, re.IGNORECASE): hash_val = text file_url = f"magnet:?xt=urn:btih:{hash_val}" logger.info(f"Processing Hash: {hash_val}. Converted to magnet. Target Folder: {parent_id or '默认(Root)'}") processed = True if file_url: safe_file_url_short = escape_markdown(file_url[:60] + "...") message_to_edit = await update.message.reply_text(f"⏳ 正在为 `{safe_file_url_short}` 创建离线任务\.\.\.", parse_mode=ParseMode.MARKDOWN_V2) try: result = await THUNDERX_CLIENT.offline_download(file_url, parent_id, name) if result and result.get("task") and result["task"].get("id"): task_id = result["task"]["id"] safe_task_id = escape_markdown(task_id) success_text = f"✅ 离线任务已创建 \(ID: `{safe_task_id}`\)" await message_to_edit.edit_text(success_text, parse_mode=ParseMode.MARKDOWN_V2) else: fail_text = f"❌ 创建离线任务失败。\nAPI 返回: `{escape_markdown(str(result)[:100])}`" await message_to_edit.edit_text(fail_text, parse_mode=ParseMode.MARKDOWN_V2) except Exception as e: # SIMPLIFICATION: Use error helper await send_telegram_error_message( target=update, error_text="创建离线任务时出错", log_message=f"Error creating offline task for URL: {file_url[:60]}...", e=e ) try: await message_to_edit.delete() except: pass return # Handled offline task # --- Unsupported Message --- if not processed and not text.startswith('/'): logger.info(f"Ignoring unsupported message format: {text[:100]}...") # await update.message.reply_text(f"ℹ️ 不支持的消息格式。请发送 magnet/http/hash/share 链接。\n使用 /help 查看帮助。") # --- Callback Handler for Copy Text --- async def handle_copy_text(update: Update, context: CallbackContext): query = update.callback_query if not await check_callback_authorization(query, context): return try: parts = query.data.split(":", 1) if len(parts) < 2: raise ValueError("Invalid copy_text data") text_to_copy = parts[1] safe_text = escape_markdown(text_to_copy) try: await query.edit_message_text(f"👇 请长按复制下面的文本:\n\n`{safe_text}`", parse_mode=ParseMode.MARKDOWN_V2) await query.answer("文本已显示,请手动复制。", show_alert=False) # Changed show_alert to False except BadRequest as e: if "message is not modified" in str(e).lower(): await query.answer() else: raise e except Exception as e: # SIMPLIFICATION: Use error helper await send_telegram_error_message( target=query, error_text="处理复制时出错", log_message=f"Error in handle_copy_text: {query.data}", e=e ) #################### 分享操作 ############################# async def tg_show_shares(update: Update, context: CallbackContext): message_to_edit = await update.message.reply_text("⏳ 正在获取分享列表\.\.\.", parse_mode=ParseMode.MARKDOWN_V2) try: shares = await THUNDERX_CLIENT.get_share_list("") keyboard = [] if not shares or 'data' not in shares or not shares['data']: await message_to_edit.edit_text("ℹ️ 未找到任何分享链接。", reply_markup=None) return header_text = "🔗 *分享列表*:\n\(点击分享标题复制分享码\)" for share in shares["data"]: # ... (Button building logic largely unchanged, ensures lengths checked) ... share_id = share.get('share_id') if not share_id: continue title = share.get('title', 'N/A') safe_display_title = escape_markdown((title[:20] + '...') if len(title) > 23 else title) share_link_text = f"share:{share_id}" callback_copy = f"copy_text:{share_link_text}" callback_del = f"del_s:{share_id}" row_buttons = [] copy_button = InlineKeyboardButton(f"📋 {safe_display_title}", callback_data=callback_copy) del_button = InlineKeyboardButton(f"❌ 取消", callback_data=callback_del) copy_ok = len(callback_copy.encode('utf-8')) <= 64 del_ok = len(callback_del.encode('utf-8')) <= 64 # ... (logic to handle long callbacks - unchanged) ... if copy_ok and del_ok: row_buttons = [copy_button, del_button] elif copy_ok: row_buttons = [copy_button, InlineKeyboardButton("❌ (ID过长)", callback_data="noop")] elif del_ok: row_buttons = [InlineKeyboardButton(f"📋 {safe_display_title} (ID过长)", callback_data="noop"), del_button] else: row_buttons = [InlineKeyboardButton(f"{safe_display_title} (ID过长无法操作)", callback_data="noop")] keyboard.append(row_buttons) reply_markup = InlineKeyboardMarkup(keyboard) await message_to_edit.edit_text(header_text, reply_markup=reply_markup, parse_mode=ParseMode.MARKDOWN_V2) except Exception as e: # SIMPLIFICATION: Use error helper await send_telegram_error_message( target=update, error_text="获取分享列表时出错", log_message="Error fetching shares", e=e ) try: await message_to_edit.delete() except: pass # --- Callback Handler Modified with Auth Check --- async def handle_share_operation(update: Update, context: CallbackContext): query = update.callback_query if not await check_callback_authorization(query, context): return try: action, share_id = query.data.split(":", 1) except ValueError: await send_telegram_error_message(query, "内部错误 (格式)", f"Invalid callback data format: {query.data}") return try: await query.answer() except Exception: pass if action == "del_s": confirm_callback = f"yes_s_del_s:{share_id}" cancel_callback = f"cancel_generic" if len(confirm_callback.encode('utf-8')) > 64: await query.edit_message_text("❌ 内部错误:无法创建确认按钮 (ID过长)。") return keyboard = [[InlineKeyboardButton("⚠️ 确认取消分享", callback_data=confirm_callback)], [InlineKeyboardButton("🔙 返回", callback_data=cancel_callback)]] reply_markup = InlineKeyboardMarkup(keyboard) safe_share_id_short = escape_markdown(share_id[:15] + "...") confirm_text = f"❓ 你确定要取消分享 `{safe_share_id_short}` 吗?" try: await query.edit_message_text(confirm_text, reply_markup=reply_markup, parse_mode=ParseMode.MARKDOWN_V2) except BadRequest as e: if "message is not modified" not in str(e).lower(): raise e except Exception as edit_e: logger.error(f"Error showing share delete confirmation: {edit_e}", exc_info=True) # --- Callback Handler Modified with Auth Check --- async def handle_share_confirmation(update: Update, context: CallbackContext): query = update.callback_query if not await check_callback_authorization(query, context): return try: parts = query.data.split(":", 1) action_details = parts[0] share_id = parts[1] except (IndexError, ValueError): await send_telegram_error_message(query, "内部错误 (格式)", f"Invalid share confirmation callback data format: {query.data}") return try: await query.answer() except Exception: pass safe_share_id_short = escape_markdown(share_id[:15] + "...") if action_details == "yes_s_del_s": logger.info(f"User confirmed deletion for share ID: {share_id}") processing_text = f"⏳ 正在取消分享 `{safe_share_id_short}`\.\.\." message_to_edit = query.message try: await message_to_edit.edit_text(processing_text, parse_mode=ParseMode.MARKDOWN_V2, reply_markup=None) except Exception as edit_e: logger.warning(f"Could not edit message to processing state: {edit_e}") try: result = await THUNDERX_CLIENT.share_batch_delete([share_id]) logger.info(f"Share deletion API call result for {share_id}: {result}") success_text = f"✅ 分享 `{safe_share_id_short}` 已取消。" await message_to_edit.edit_text(success_text, parse_mode=ParseMode.MARKDOWN_V2) except Exception as e: # SIMPLIFICATION: Use error helper (editing the processing message) await send_telegram_error_message( target=query, # Pass query to allow editing error_text=f"取消分享 `{safe_share_id_short}` 时出错", log_message=f"Error deleting share {share_id}", e=e ) else: logger.warning(f"Received unexpected share confirmation action: {query.data}") await query.edit_message_text(f"❓ 未知的确认操作: `{escape_markdown(query.data)}`", parse_mode=ParseMode.MARKDOWN_V2) # --- Generic Cancel Handler --- async def handle_cancel_generic(update: Update, context: CallbackContext): query = update.callback_query if not await check_callback_authorization(query, context): return try: await query.answer() await query.edit_message_text(f"ℹ️ 操作已取消。") except BadRequest as e: if "message is not modified" in str(e).lower(): logger.warning(f"Msg not modified: {e}") else: logger.error(f"Error editing cancel msg: {e}", exc_info=True) except Exception as e: logger.error(f"Error handling cancel: {e}", exc_info=True) #################### 文件操作 (with Pagination) ############################# # --- Main function to show files with pagination --- async def tg_show_files(update: Update, context: CallbackContext, folder_id: str = "", page: int = 1): is_callback = update.callback_query is not None query = update.callback_query target_message = query.message if is_callback else update.message chat_id = target_message.chat_id if target_message else None if not target_message or not chat_id: return logger.info(f"Showing files for folder_id: '{folder_id or 'Root'}' | Page: {page}") loading_text = f"⏳ 正在加载文件夹内容 \(页 {escape_markdown(str(page))}\)\.\.\." message_to_edit = target_message try: if is_callback: await message_to_edit.edit_text(loading_text, parse_mode=ParseMode.MARKDOWN_V2, reply_markup=None) else: message_to_edit = await target_message.reply_text(loading_text, parse_mode=ParseMode.MARKDOWN_V2) except Exception as e: logger.warning(f"Could not set loading state: {e}") try: files_per_api_call = 200 files_data = await THUNDERX_CLIENT.file_list( size=files_per_api_call, parent_id=folder_id, next_page_token="", additional_filters={"trashed": {"eq": False}, "phase": {"eq": "PHASE_TYPE_COMPLETE"}} ) all_items = files_data.get('files', []) if files_data else [] folders = sorted([f for f in all_items if f.get('kind') == 'drive#folder'], key=lambda x: x.get('name', '').lower()) files = sorted([f for f in all_items if f.get('kind') == 'drive#file'], key=lambda x: x.get('name', '').lower()) sorted_items = folders + files total_items = len(sorted_items) total_pages = (total_items + ITEMS_PER_PAGE - 1) // ITEMS_PER_PAGE if total_items > 0 else 1 page = max(1, min(page, total_pages)) start_index = (page - 1) * ITEMS_PER_PAGE end_index = start_index + ITEMS_PER_PAGE paginated_items = sorted_items[start_index:end_index] keyboard = [] parent_folder_id = "" current_folder_name_safe = escape_markdown("根目录") if folder_id: try: folder_info = await THUNDERX_CLIENT.get_file_info(folder_id) if folder_info and folder_info.get('kind') == 'drive#folder': parent_folder_id = folder_info.get('parent_id', "") current_folder_name_safe = escape_markdown(folder_info.get('name', f'ID_{folder_id[-6:]}')) else: parent_folder_id, current_folder_name_safe = "", escape_markdown(f"未知位置_{folder_id[-6:]}") except Exception as e: logger.warning(f"Could not fetch info for folder {folder_id}: {e}"); parent_folder_id, current_folder_name_safe = "", escape_markdown(f"错误_{folder_id[-6:]}") else: parent_folder_id = "" message_text_prefix = f"📂 `{current_folder_name_safe}`" if folder_id: back_callback = f"ls_b:{parent_folder_id}:1" if len(back_callback.encode('utf-8')) <= 64: keyboard.append([InlineKeyboardButton(f"↩️ 返回上级", callback_data=back_callback)]) else: keyboard.append([InlineKeyboardButton(f"↩️ 返回上级 (ID过长)", callback_data="noop")]) if not paginated_items and total_items == 0: message_text = f"{message_text_prefix} \(目录为空\)" else: message_text = f"{message_text_prefix} \(第 {escape_markdown(str(page))}/{escape_markdown(str(total_pages))} 页\)" for item in paginated_items: # ... (Button building logic largely unchanged) ... item_id, item_name, item_kind = item.get('id'), item.get('name', 'N/A'), item.get('kind', 'drive#file') if not item_id: continue safe_display_name_short = escape_markdown((item_name[:20] + '...') if len(item_name) > 23 else item_name) row, action_buttons, item_button = [], [], None delete_cb, share_cb, download_cb, list_cb = f"del_f:{item_id}:{folder_id}:{page}", f"sh_f:{item_id}:{folder_id}:{page}", f"dw_f:{item_id}:{folder_id}:{page}", f"ls_f:{item_id}:1" del_ok, share_ok, dw_ok, list_ok = (len(cb.encode('utf-8')) <= 64 for cb in [delete_cb, share_cb, download_cb, list_cb]) if item_kind == 'drive#folder': item_button = InlineKeyboardButton(f"📁 {safe_display_name_short}", callback_data=list_cb if list_ok else "noop") action_buttons.append(InlineKeyboardButton("🔗", callback_data=share_cb if share_ok else "noop")) action_buttons.append(InlineKeyboardButton("🗑️", callback_data=delete_cb if del_ok else "noop")) if not list_ok or not share_ok or not del_ok: if not list_ok: item_button = InlineKeyboardButton(f"📁 {safe_display_name_short} (ID过长)", callback_data="noop") if not share_ok: action_buttons[0] = InlineKeyboardButton("🔗(ID过长)", callback_data="noop") if not del_ok: action_buttons[1] = InlineKeyboardButton("🗑️(ID过长)", callback_data="noop") else: safe_size_str = escape_markdown(f"({format_bytes(item.get('size'))})") file_display_text = f"📄 {safe_display_name_short} {safe_size_str}" item_button = InlineKeyboardButton(file_display_text, callback_data=download_cb if dw_ok else "noop") action_buttons.append(InlineKeyboardButton("🔗", callback_data=share_cb if share_ok else "noop")) action_buttons.append(InlineKeyboardButton("🗑️", callback_data=delete_cb if del_ok else "noop")) if not dw_ok or not share_ok or not del_ok: if not dw_ok: item_button = InlineKeyboardButton(f"📄 {safe_display_name_short} (ID过长)", callback_data="noop") if not share_ok: action_buttons[0] = InlineKeyboardButton("🔗(ID过长)", callback_data="noop") if not del_ok: action_buttons[1] = InlineKeyboardButton("🗑️(ID过长)", callback_data="noop") row.append(item_button); row.extend(action_buttons); keyboard.append(row) page_row = [] if page > 1: prev_callback = f"ls_p:{folder_id}:{page-1}" page_row.append(InlineKeyboardButton("⬅️ 上一页", callback_data=prev_callback) if len(prev_callback.encode('utf-8')) <= 64 else InlineKeyboardButton("⬅️ (错误)", callback_data="noop")) if page < total_pages: next_callback = f"ls_n:{folder_id}:{page+1}" page_row.append(InlineKeyboardButton("下一页 ➡️", callback_data=next_callback) if len(next_callback.encode('utf-8')) <= 64 else InlineKeyboardButton("➡️ (错误)", callback_data="noop")) if page_row: keyboard.append(page_row) reply_markup = InlineKeyboardMarkup(keyboard) if keyboard else None try: current_text_md = message_to_edit.text_markdown_v2_urled or message_to_edit.text_markdown_v2 if current_text_md == message_text and message_to_edit.reply_markup == reply_markup: if query: await query.answer() else: await message_to_edit.edit_text(message_text, parse_mode=ParseMode.MARKDOWN_V2, reply_markup=reply_markup) if query: await query.answer() except BadRequest as e: if "message is not modified" in str(e).lower(): if query: await query.answer() else: raise e # Re-raise other BadRequests except Exception as edit_error: logger.error(f"Failed to edit message for file list: {edit_error}", exc_info=True) if query: await query.answer("❌ 更新列表时出错", show_alert=True) except Exception as e: # SIMPLIFICATION: Use error helper await send_telegram_error_message( target=query if is_callback else update, error_text="获取文件列表时出错", log_message=f"Error showing files for folder {folder_id}, page {page}", e=e ) # We attempt to edit the loading message within send_telegram_error_message # --- Initial command call --- async def tg_show_files_command(update: Update, context: CallbackContext): if not update.message: return await tg_show_files(update, context, folder_id="", page=1) # --- Callback Handlers for Pagination --- async def handle_prev_page(update: Update, context: CallbackContext): query = update.callback_query if not await check_callback_authorization(query, context): return try: _, folder_id, page_str = query.data.split(":", 2) page = max(1, int(page_str)) # Go back to previous page await tg_show_files(update, context, folder_id=folder_id, page=page) except Exception as e: await send_telegram_error_message(query, "处理上一页时出错", f"Error handling prev page: {query.data}", e) async def handle_next_page(update: Update, context: CallbackContext): query = update.callback_query if not await check_callback_authorization(query, context): return try: _, folder_id, page_str = query.data.split(":", 2) page = int(page_str) # Go to next page await tg_show_files(update, context, folder_id=folder_id, page=page) except Exception as e: await send_telegram_error_message(query, "处理下一页时出错", f"Error handling next page: {query.data}", e) async def handle_back_folder(update: Update, context: CallbackContext): query = update.callback_query if not await check_callback_authorization(query, context): return try: _, folder_id, page_str = query.data.split(":", 2) # Page is always 1 when going back await tg_show_files(update, context, folder_id=folder_id, page=1) except Exception as e: await send_telegram_error_message(query, "处理返回上级时出错", f"Error handling back folder: {query.data}", e) # --- Callback Handler Modified with Auth Check --- async def handle_file_confirmation(update: Update, context: CallbackContext): query = update.callback_query if not await check_callback_authorization(query, context): return try: action_details, file_id, parent_id, page_str = query.data.split(":", 3) page = int(page_str) except (IndexError, ValueError): await send_telegram_error_message(query, "内部错误 (格式)", f"Invalid file confirmation callback data format: {query.data}") return try: await query.answer() except Exception: pass safe_file_id_short = escape_markdown(file_id[:15] + "...") if action_details == "yes_f_del_f": logger.info(f"User confirmed deletion for file ID: {file_id}") processing_text = f"⏳ 正在将 `{safe_file_id_short}` 移至回收站\.\.\." message_to_edit = query.message try: await message_to_edit.edit_text(processing_text, parse_mode=ParseMode.MARKDOWN_V2, reply_markup=None) except Exception as edit_e: logger.warning(f"Could not edit msg: {edit_e}") try: result = await THUNDERX_CLIENT.delete_to_trash([file_id]) logger.info(f"File deletion API call result for {file_id}: {result}") success_text = f"✅ 文件/文件夹 `{safe_file_id_short}` 已移至回收站。" await message_to_edit.edit_text(success_text, parse_mode=ParseMode.MARKDOWN_V2) except Exception as e: # SIMPLIFICATION: Use error helper await send_telegram_error_message( target=query, # Pass query to allow editing error_text=f"删除 `{safe_file_id_short}` 时出错", log_message=f"Error deleting file {file_id}", e=e ) else: logger.warning(f"Received unexpected file confirmation action: {query.data}") await query.edit_message_text(f"❓ 未知的确认操作: `{escape_markdown(query.data)}`", parse_mode=ParseMode.MARKDOWN_V2) # --- Callback Handler Modified with Auth Check --- async def handle_file_operation(update: Update, context: CallbackContext): query = update.callback_query if not await check_callback_authorization(query, context): return try: parts = query.data.split(":") action = parts[0] if action == "ls_f": if len(parts) != 3: raise ValueError("Invalid ls_f format") target_folder_id, page = parts[1], int(parts[2]) await tg_show_files(update, context, folder_id=target_folder_id, page=page) elif action in ["del_f", "sh_f", "dw_f"]: if len(parts) != 4: raise ValueError("Invalid file action format") file_id, folder_id, page = parts[1], parts[2], int(parts[3]) try: await query.answer() except Exception: pass await perform_file_action(update, context, action, file_id, folder_id, page) elif action == "noop": await query.answer("ℹ️ 此按钮无效 (可能因ID过长)。") else: raise ValueError(f"Unknown file action: {action}") except (ValueError, IndexError) as e: await send_telegram_error_message(query, "内部错误 (格式)", f"Invalid file operation callback data: {query.data}, Error: {e}") except Exception as e: await send_telegram_error_message(query, "处理文件操作时出错", f"Error in handle_file_operation: {query.data}", e) async def perform_file_action( update: Update, context: CallbackContext, action: str, file_id: str, folder_id: str, page: int ): query = update.callback_query if not query or not query.message: return safe_file_id_short = escape_markdown(file_id[:15] + "...") original_message_text = query.message.text_markdown_v2_urled or query.message.text_markdown_v2 or query.message.text original_reply_markup = query.message.reply_markup message_to_edit = query.message # Reference for potential edits if action == "del_f": confirm_callback, cancel_callback = f"yes_f_del_f:{file_id}:{folder_id}:{page}", "cancel_generic" if len(confirm_callback.encode('utf-8')) > 64: await message_to_edit.edit_text("❌ 内部错误:无法创建确认按钮 (ID过长)。") return keyboard = [[InlineKeyboardButton("⚠️ 确认移到回收站", callback_data=confirm_callback)], [InlineKeyboardButton("🔙 取消", callback_data=cancel_callback)]] reply_markup = InlineKeyboardMarkup(keyboard) confirm_text = f"❓ 你确定要把 `{safe_file_id_short}` 移到回收站吗?" try: await message_to_edit.edit_text(confirm_text, reply_markup=reply_markup, parse_mode=ParseMode.MARKDOWN_V2) except BadRequest as e: if "message is not modified" not in str(e).lower(): raise e except Exception as e: logger.error(f"Error showing delete confirmation: {e}", exc_info=True) elif action == "dw_f": logger.info(f"Requesting download URL for file ID: {file_id}") loading_text = f"⏳ 正在获取文件 `{safe_file_id_short}` 的下载链接\.\.\." try: await message_to_edit.edit_text(loading_text, parse_mode=ParseMode.MARKDOWN_V2, reply_markup=None) except Exception as e: logger.warning(f"Could not edit msg: {e}") try: result = await THUNDERX_CLIENT.get_download_url(file_id) download_url = result.get("web_content_link") if not download_url and result.get("medias"): # Fallback for media in result.get("medias", []): if media.get("link") and media["link"].get("url"): download_url = media["link"]["url"]; break if download_url: logger.info(f"Successfully obtained download URL for {file_id}") link_message_text = f"📄 文件 `{safe_file_id_short}` 下载链接 \(有效期较短\):\n\n[点此下载]({download_url})" await message_to_edit.reply_text(link_message_text, parse_mode=ParseMode.MARKDOWN_V2, disable_web_page_preview=True) confirm_text = f"✅ 已发送 `{safe_file_id_short}` 的下载链接 \(见上方新消息\)。\n\n{original_message_text or ''}" try: await message_to_edit.edit_text(confirm_text, parse_mode=ParseMode.MARKDOWN_V2, reply_markup=original_reply_markup) except Exception as edit_e: logger.warning(f"Could not edit confirm msg: {edit_e}") else: logger.error(f"Could not find download URL for {file_id}. Response: {result}") error_text = f"❌ 未找到文件 `{safe_file_id_short}` 的下载链接!\nAPI响应: `{escape_markdown(str(result)[:100])}`" try: await message_to_edit.edit_text(error_text + f"\n\n{original_message_text or ''}", parse_mode=ParseMode.MARKDOWN_V2, reply_markup=original_reply_markup) except Exception as edit_e: logger.warning(f"Could not edit error msg: {edit_e}") except Exception as e: # SIMPLIFICATION: Use error helper await send_telegram_error_message( target=query, error_text=f"获取 `{safe_file_id_short}` 下载链接时出错", log_message=f"Error getting download URL for {file_id}", e=e ) # Attempt to restore original message context if edit failed in helper try: await message_to_edit.edit_text(original_message_text or "错误", reply_markup=original_reply_markup, parse_mode=ParseMode.MARKDOWN_V2) except: pass elif action == "sh_f": logger.info(f"Requesting share creation for file ID: {file_id}") loading_text = f"⏳ 正在为 `{safe_file_id_short}` 创建分享链接\.\.\." try: await message_to_edit.edit_text(loading_text, parse_mode=ParseMode.MARKDOWN_V2, reply_markup=None) except Exception as e: logger.warning(f"Could not edit msg: {e}") try: result = await THUNDERX_CLIENT.file_batch_share([file_id], need_password=False, expiration_days=-1) share_id = result.get("share_id") if share_id: logger.info(f"Successfully created share for {file_id}: {share_id}") share_link_text = f"share:{share_id}"; copy_callback = f"copy_text:{share_link_text}"; copy_button = None if len(copy_callback.encode('utf-8')) <= 64: copy_button = InlineKeyboardButton(f"📋 点击复制分享码", callback_data=copy_callback) safe_share_link_display = escape_markdown(share_link_text) share_message_text = f"✅ 分享码已生成:\n`{safe_share_link_display}`" await message_to_edit.reply_text(share_message_text, reply_markup=InlineKeyboardMarkup([[copy_button]]) if copy_button else None, parse_mode=ParseMode.MARKDOWN_V2) confirm_text = f"✅ 已发送 `{safe_file_id_short}` 的分享链接 \(见上方新消息\)。\n\n{original_message_text or ''}" try: await message_to_edit.edit_text(confirm_text, parse_mode=ParseMode.MARKDOWN_V2, reply_markup=original_reply_markup) except Exception as edit_e: logger.warning(f"Could not edit confirm msg: {edit_e}") else: logger.error(f"Failed to create share for {file_id}. API Response: {result}") error_text = f"❌ 为 `{safe_file_id_short}` 创建分享失败!\nAPI响应: `{escape_markdown(str(result)[:100])}`" try: await message_to_edit.edit_text(error_text + f"\n\n{original_message_text or ''}", parse_mode=ParseMode.MARKDOWN_V2, reply_markup=original_reply_markup) except Exception as edit_e: logger.warning(f"Could not edit error msg: {edit_e}") except Exception as e: # SIMPLIFICATION: Use error helper await send_telegram_error_message( target=query, error_text=f"为 `{safe_file_id_short}` 创建分享时出错", log_message=f"Error creating share for {file_id}", e=e ) try: await message_to_edit.edit_text(original_message_text or "错误", reply_markup=original_reply_markup, parse_mode=ParseMode.MARKDOWN_V2) except: pass #################### 离线任务处理 ########################## async def tg_show_task(update: Update, context: CallbackContext): logger.info("Fetching offline task list.") is_callback = update.callback_query is not None query = update.callback_query target_message = query.message if is_callback else update.message chat_id = target_message.chat_id if target_message else None if not target_message or not chat_id: return loading_text = "⏳ 正在获取任务列表..." message_to_edit = target_message try: if is_callback: await message_to_edit.edit_text(loading_text, reply_markup=None) else: message_to_edit = await target_message.reply_text(loading_text) except Exception as e: logger.warning(f"Could not set loading state: {e}") try: tasks_data = await THUNDERX_CLIENT.offline_list(size=50, next_page_token=None, phase=None) keyboard = [] if not tasks_data or 'tasks' not in tasks_data or not tasks_data['tasks']: await message_to_edit.edit_text("ℹ️ 当前没有离线下载任务。", reply_markup=None) return tasks_list_text = f"🚀 *离线任务列表*:" for task in tasks_data["tasks"]: # ... (Task button building logic unchanged) ... task_id, task_name = task.get('id'), task.get('name', 'N/A') task_phase_raw, task_progress, task_message = task.get('phase', 'UNKNOWN'), task.get('progress', 0), task.get('message', '') if not task_id: continue task_phase = task_phase_raw.replace('PHASE_TYPE_', '') status_emoji = "✅" if task_phase == "COMPLETE" else "⏳" if task_phase in ["PENDING", "RUNNING"] else "🌱" if task_phase == "SEEDING" else "❌" if task_phase == "ERROR" else "❓" # Added seeding emoji safe_display_name = escape_markdown((task_name[:25] + '...') if len(task_name) > 28 else task_name) safe_phase = escape_markdown(task_phase); safe_progress = escape_markdown(f"[{task_progress}%]") status_line = f"{status_emoji} {safe_display_name} \({safe_phase}\) {safe_progress}" if task_phase == "ERROR" and task_message: status_line += f"\n └─ 错误: `{escape_markdown(task_message[:30] + '...')}`" delete_callback = f"delete_task:{task_id}" row = [InlineKeyboardButton(status_line, callback_data="noop")] if len(delete_callback.encode('utf-8')) <= 64: row.append(InlineKeyboardButton("🗑️ 删除", callback_data=delete_callback)) else: row.append(InlineKeyboardButton("🗑️ (ID过长)", callback_data="noop")) keyboard.append(row) if tasks_data.get('next_page_token'): keyboard.append([InlineKeyboardButton("...(更多任务未显示 - 暂不支持翻页)", callback_data="noop")]) refresh_callback = "refresh_tasks" keyboard.append([InlineKeyboardButton("🔄 刷新列表", callback_data=refresh_callback)]) reply_markup = InlineKeyboardMarkup(keyboard) await message_to_edit.edit_text(tasks_list_text, reply_markup=reply_markup, parse_mode=ParseMode.MARKDOWN_V2) except Exception as e: # SIMPLIFICATION: Use error helper await send_telegram_error_message( target=query if is_callback else update, error_text="获取离线任务列表时出错", log_message="Error fetching/editing offline tasks", e=e ) # Don't delete message_to_edit here, as the error msg should replace it # --- Callback Handler for Refresh Tasks --- async def handle_refresh_tasks(update: Update, context: CallbackContext): query = update.callback_query if not await check_callback_authorization(query, context): return try: await query.answer("正在刷新...") await tg_show_task(update, context) # Call main function except Exception as e: # Error during refresh itself (not the initial fetch) await send_telegram_error_message(query, "刷新失败", f"Error refreshing tasks: {query.data}", e) # --- Callback Handler Modified with Auth Check --- async def handle_tasks_operation(update: Update, context: CallbackContext): query = update.callback_query if not await check_callback_authorization(query, context): return try: action, task_id = query.data.split(":", 1) except ValueError: await send_telegram_error_message(query, "内部错误 (格式)", f"Invalid task operation callback data: {query.data}") return try: await query.answer() except Exception: pass safe_task_id_short = escape_markdown(task_id[:15] + "...") if action == "delete_task": confirm_callback, cancel_callback = f"confirm_task_delete_task:{task_id}", "refresh_tasks" if len(confirm_callback.encode('utf-8')) > 64: await query.edit_message_text("❌ 内部错误:无法创建确认按钮 (ID过长)。") return keyboard = [[InlineKeyboardButton("⚠️ 确认删除任务", callback_data=confirm_callback)], [InlineKeyboardButton("🔙 返回列表", callback_data=cancel_callback)]] reply_markup = InlineKeyboardMarkup(keyboard) confirm_text = f"❓ 你确定要删除任务 `{safe_task_id_short}` 吗?\n(注意:这 *不会* 删除已下载的文件)" try: await query.edit_message_text(confirm_text, reply_markup=reply_markup, parse_mode=ParseMode.MARKDOWN_V2) except BadRequest as e: if "message is not modified" not in str(e).lower(): raise e except Exception as e: logger.error(f"Error showing task delete confirmation: {e}", exc_info=True) elif action == "noop": await query.answer("ℹ️ 此按钮无效。") else: logger.warning(f"Received unexpected task action: {action}") await query.edit_message_text(f"❓ 未知任务操作: `{escape_markdown(action)}`", parse_mode=ParseMode.MARKDOWN_V2) # --- Callback Handler Modified with Auth Check --- async def handle_task_confirmation(update: Update, context: CallbackContext): query = update.callback_query if not await check_callback_authorization(query, context): return try: action_details, task_id = query.data.split(":", 1) except (IndexError, ValueError): await send_telegram_error_message(query, "内部错误 (格式)", f"Invalid task confirmation callback data format: {query.data}") return try: await query.answer() except Exception: pass safe_task_id_short = escape_markdown(task_id[:15] + "...") if action_details == "confirm_task_delete_task": logger.info(f"User confirmed deletion for task ID: {task_id}") processing_text = f"⏳ 正在删除任务 `{safe_task_id_short}`\.\.\." message_to_edit = query.message try: await message_to_edit.edit_text(processing_text, parse_mode=ParseMode.MARKDOWN_V2, reply_markup=None) except Exception as edit_e: logger.warning(f"Could not edit msg: {edit_e}") try: result = await THUNDERX_CLIENT.delete_tasks([task_id], delete_files=False) logger.info(f"Task deletion API call result for {task_id}: {result}") success_text = f"✅ 任务 `{safe_task_id_short}` 已删除。" keyboard = [[InlineKeyboardButton("🔄 刷新任务列表", callback_data="refresh_tasks")]] await message_to_edit.edit_text(success_text, parse_mode=ParseMode.MARKDOWN_V2, reply_markup=InlineKeyboardMarkup(keyboard)) except Exception as e: # SIMPLIFICATION: Use error helper await send_telegram_error_message( target=query, # Pass query error_text=f"删除任务 `{safe_task_id_short}` 时出错", log_message=f"Error deleting task {task_id}", e=e ) # Add refresh button even on error (the helper will edit the message) try: keyboard = [[InlineKeyboardButton("🔄 刷新任务列表", callback_data="refresh_tasks")]] await query.message.edit_reply_markup(reply_markup=InlineKeyboardMarkup(keyboard)) except Exception as final_edit_e: logger.warning(f"Could not add refresh button after task delete error: {final_edit_e}") else: logger.warning(f"Received unexpected task confirmation action: {query.data}") await query.edit_message_text(f"❓ 未知的确认操作: `{escape_markdown(query.data)}`", parse_mode=ParseMode.MARKDOWN_V2) @app.on_event("startup") async def init_client(): global THUNDERX_CLIENT, TG_BOT_APPLICATION, scheduler, ALLOWED_USER_ID logger.info("===== Application Startup =====") # --- PikPak Client Initialization (Unchanged) --- token_file = "thunderx.json" # ... (Existing PikPak loading/login logic) ... if os.path.exists(token_file): logger.info(f"Loading PikPak client state from {token_file}") try: with open(token_file, "r") as f: data = json.load(f) httpx_args = {"proxies": PROXY_URL, "timeout": 300, "follow_redirects": True} if PROXY_URL else {"timeout": 300, "follow_redirects": True} THUNDERX_CLIENT = PikPakApi.from_dict(data, httpx_client_args=httpx_args) THUNDERX_CLIENT.token_refresh_callback = log_token THUNDERX_CLIENT.token_refresh_callback_kwargs = {"extra_data": "loaded_refresh"} logger.info("PikPak client loaded. Verifying token...") user_info = await THUNDERX_CLIENT.get_user_info() logger.info(f"Token verified. User: {user_info.get('name', 'N/A')}") except Exception as e: logger.error(f"Failed to load or validate PikPak client from {token_file}: {e}. Will attempt new login.", exc_info=True) THUNDERX_CLIENT = None else: logger.info(f"{token_file} not found. Attempting new login.") if THUNDERX_CLIENT is None: logger.info("Initializing new PikPak client and logging in...") try: THUNDERX_CLIENT = PikPakApi( username=THUNDERX_USERNAME, password=THUNDERX_PASSWORD, httpx_client_args={"proxies": PROXY_URL, "timeout": 300, "follow_redirects": True} if PROXY_URL else {"timeout": 300, "follow_redirects": True}, token_refresh_callback=log_token, token_refresh_callback_kwargs={"extra_data": "startup_refresh"}, ) await THUNDERX_CLIENT.login() with open(token_file, "w") as f: f.write(json.dumps(THUNDERX_CLIENT.to_dict(), indent=4)) logger.info(f"Login successful. Client state saved to {token_file}.") user_info = THUNDERX_CLIENT.get_user_info() logger.info(f"User Info (New Login): {user_info.get('name', 'N/A')}") except Exception as e: raise RuntimeError(f"Could not login to PikPak: {e}") # --- Initialize Telegram Bot (Unchanged logic, just handler registration) --- if not TG_BOT_TOKEN: logger.warning("TG_BOT_TOKEN not set. Telegram bot functionality disabled.") else: logger.info("Initializing Telegram Bot...") if not ALLOWED_USER_ID: logger.critical("TG_ADMIN_CHAT_ID is not set or invalid. Bot WILL NOT be started.") TG_BOT_APPLICATION = None else: try: TG_BOT_APPLICATION = Application.builder().base_url(TG_BASE_URL).token(TG_BOT_TOKEN).build() if TG_WEBHOOK_URL: logger.info(f"Setting webhook to: {TG_WEBHOOK_URL}") webhook_set = await TG_BOT_APPLICATION.bot.set_webhook(url=TG_WEBHOOK_URL, allowed_updates=Update.ALL_TYPES) if webhook_set: logger.info("Webhook set successfully.") else: logger.error("Failed to set webhook!") else: logger.warning("TG_WEBHOOK_URL not set.") user_filter = filters.User(user_id=ALLOWED_USER_ID) logger.info(f"Applying Telegram user filter for User ID: {ALLOWED_USER_ID}") # Register Handlers (Same handlers as before) TG_BOT_APPLICATION.add_handler(CommandHandler("start", start, filters=user_filter)) TG_BOT_APPLICATION.add_handler(CommandHandler("help", help_command, filters=user_filter)) TG_BOT_APPLICATION.add_handler(CommandHandler("quota", quota, filters=user_filter)) TG_BOT_APPLICATION.add_handler(CommandHandler("emptytrash", tg_emptytrash, filters=user_filter)) TG_BOT_APPLICATION.add_handler(CommandHandler("tasks", tg_show_task, filters=user_filter)) TG_BOT_APPLICATION.add_handler(CommandHandler("files", tg_show_files_command, filters=user_filter)) TG_BOT_APPLICATION.add_handler(CommandHandler("shares", tg_show_shares, filters=user_filter)) TG_BOT_APPLICATION.add_handler(MessageHandler(user_filter & filters.TEXT & ~filters.COMMAND, handle_message)) TG_BOT_APPLICATION.add_handler(CallbackQueryHandler(handle_copy_text, pattern="^copy_text:")) TG_BOT_APPLICATION.add_handler(CallbackQueryHandler(handle_cancel_generic, pattern="^cancel_generic$")) TG_BOT_APPLICATION.add_handler(CallbackQueryHandler(handle_refresh_tasks, pattern="^refresh_tasks$")) TG_BOT_APPLICATION.add_handler(CallbackQueryHandler(handle_task_confirmation, pattern="^confirm_task_delete_task:")) TG_BOT_APPLICATION.add_handler(CallbackQueryHandler(handle_tasks_operation, pattern="^(delete_task|noop):")) TG_BOT_APPLICATION.add_handler(CallbackQueryHandler(handle_share_confirmation, pattern="^yes_s_del_s:")) TG_BOT_APPLICATION.add_handler(CallbackQueryHandler(handle_share_operation, pattern="^del_s:")) TG_BOT_APPLICATION.add_handler(CallbackQueryHandler(handle_file_confirmation, pattern="^yes_f_del_f:")) TG_BOT_APPLICATION.add_handler(CallbackQueryHandler(handle_prev_page, pattern="^ls_p:")) TG_BOT_APPLICATION.add_handler(CallbackQueryHandler(handle_next_page, pattern="^ls_n:")) TG_BOT_APPLICATION.add_handler(CallbackQueryHandler(handle_back_folder, pattern="^ls_b:")) TG_BOT_APPLICATION.add_handler(CallbackQueryHandler(handle_file_operation, pattern="^(ls_f|del_f|dw_f|sh_f|noop):")) logger.info(f"Registered Telegram handlers restricted to User ID: {ALLOWED_USER_ID}") await TG_BOT_APPLICATION.initialize() logger.info("Telegram Bot application initialized.") except Exception as e: logger.error(f"Failed to initialize Telegram Bot: {e}", exc_info=True) TG_BOT_APPLICATION = None # --- Initialize Scheduler (Unchanged) --- if CLEANUP_ENABLED and THUNDERX_CLIENT: cleanup_mode = "递归" if CLEANUP_RECURSIVE else "非递归" target_desc = f"目标文件夹: {CLEANUP_TARGET_FOLDER_ID}" if CLEANUP_TARGET_FOLDER_ID and not CLEANUP_RECURSIVE else "根目录" if not CLEANUP_RECURSIVE else "根目录及子目录" if CLEANUP_RECURSIVE else "整个云盘" logger.info(f"调度小文件清理任务 ({cleanup_mode}, {target_desc}) 每 {CLEANUP_INTERVAL_HOURS} 小时执行一次。阈值: {CLEANUP_SIZE_THRESHOLD_MB}MB。") try: scheduler.add_job( cleanup_small_files, trigger=IntervalTrigger(hours=CLEANUP_INTERVAL_HOURS), id="cleanup_small_files_job", name=f"PikPak Small File Cleanup ({cleanup_mode})", replace_existing=True, next_run_time=datetime.now() + timedelta(minutes=5) ) scheduler.start() logger.info("调度器已成功启动。") except Exception as e: logger.error(f"无法调度清理任务: {e}", exc_info=True) elif not CLEANUP_ENABLED: logger.info("自动小文件清理已禁用 (CLEANUP_ENABLED=false)。") elif not THUNDERX_CLIENT: logger.warning("无法启动清理任务,因为 PikPak 客户端不可用。") @app.on_event("shutdown") async def shutdown_event(): logger.info("===== Application Shutdown =====") # --- Shutdown logic (Unchanged) --- if scheduler and scheduler.running: scheduler.shutdown(); logger.info("Scheduler stopped.") if TG_BOT_APPLICATION: await TG_BOT_APPLICATION.shutdown(); logger.info("Telegram Bot application shut down.") if THUNDERX_CLIENT: await THUNDERX_CLIENT.close(); logger.info("PikPak client connection closed.") # --- Webhook Endpoint (Unchanged) --- @app.post("/webhook", include_in_schema=False) async def webhook(request: Request): if not TG_BOT_APPLICATION: raise HTTPException(status_code=503, detail="Telegram Bot not available") try: data = await request.json() update = Update.de_json(data, TG_BOT_APPLICATION.bot) logger.debug(f"Processing update ID: {update.update_id}") await TG_BOT_APPLICATION.process_update(update) return JSONResponse({"status": "ok"}) except json.JSONDecodeError: logger.error("Failed to decode JSON from webhook."); raise HTTPException(status_code=400, detail="Invalid JSON received") except Exception as e: logger.error(f"Error processing webhook update: {e}", exc_info=True); return JSONResponse({"status": "error processing update"}, status_code=200) # --- Frontend Route (Unchanged) --- @front_router.get("/", response_class=HTMLResponse, summary="前台页面", description="前台管理页面", tags=["前端"]) async def home(request: Request): context = {"request": request}; return templates.TemplateResponse("index.html", context) # --- API Endpoints (Largely unchanged, rely on existing logging/error handling) --- # ... (All @api_router endpoints remain the same as in your provided code) ... # Example of one endpoint structure (rest are similar) @api_router.post("/files", summary="文件列表", description="获取文件列表", tags=["文件"]) async def get_files(item: FileRequest): logger.info(f"API Request: Get files. ParentID: {item.parent_id or 'root'}, Size: {item.size}, Token: {item.next_page_token}, Filters: {item.additional_filters}") try: api_filters = item.additional_filters or {} if 'trashed' not in api_filters: api_filters['trashed'] = {"eq": False} if 'phase' not in api_filters: api_filters['phase'] = {"eq": "PHASE_TYPE_COMPLETE"} return await THUNDERX_CLIENT.file_list(item.size, item.parent_id, item.next_page_token, api_filters) except Exception as e: logger.error(f"API Error - file_list: {e}", exc_info=True) raise HTTPException(status_code=500, detail=f"Failed to fetch file list from PikPak: {str(e)}") # --- PASTE THE REST OF YOUR @api_router endpoints here --- # (get_file_info, offline, file_star_list, delete_file_info, file_rename, # file_batch_copy, file_batch_move, create_folder, delete_to_trash_api, # delete_forever_api, untrash_api, file_batch_star, file_batch_unstar, # emptytrash_api, get_share_list, file_batch_share, share_batch_delete, # get_share_folder, restore_api, offline_list, delete_tasks_api, userinfo, # quota_info_api, get_invite_code) # Make sure they follow the same pattern of logging and try/except raising HTTPException @api_router.post( "/offline", summary="添加离线任务", description="添加离线任务 (支持 magnet, http(s), ed2k, hash)", tags=["离线任务"] ) async def offline(item: OfflineRequest): file_url = item.file_url.strip() parent_id = item.parent_id if item.parent_id is not None else DEFAULT_DOWNLOAD_FOLDER_ID name = item.name logger.info(f"API Request: Add offline task. URL/Hash: {file_url[:60]}..., ParentID: {parent_id or 'Default(Root)'}, Name: {name or 'Auto'}") if re.fullmatch(r'[a-fA-F0-9]{40}', file_url, re.IGNORECASE): file_url = f"magnet:?xt=urn:btih:{file_url}" elif not file_url.lower().startswith(("magnet:", "http:", "https:", "ftp:", "ftps:", "ed2k:")): raise HTTPException(status_code=400, detail="Unsupported URL scheme or invalid input.") if not file_url: raise HTTPException(status_code=400, detail="file_url (or hash) is required.") try: result = await THUNDERX_CLIENT.offline_download(file_url, parent_id, name) if result and result.get("task") and result["task"].get("id"): return result else: error_detail = result.get("error_description", result.get("error", "Unknown error")) if isinstance(result, dict) else str(result) raise HTTPException(status_code=400, detail=f"Failed to create task on PikPak: {error_detail}") except Exception as e: raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}") @api_router.get("/files/{file_id}", summary="文件信息", tags=["文件"]) async def get_file_info(file_id: str = Path(...)): logger.info(f"API Request: Get file info ID: {file_id}") try: result = await THUNDERX_CLIENT.get_file_info(file_id) if not result: raise HTTPException(status_code=404, detail="File not found") if isinstance(result, dict) and result.get("error"): raise HTTPException(status_code=400, detail=result.get("error_description", "API error")) return result except Exception as e: raise HTTPException(status_code=500, detail=f"Server error: {str(e)}") @api_router.post("/file_star_list", summary="加星文件列表", tags=["文件"]) async def file_star_list(size: int = Query(100), next_page_token: Optional[str] = Query(None)): logger.info(f"API Request: Get starred files. Size: {size}, Token: {next_page_token}") try: return await THUNDERX_CLIENT.file_star_list(size, next_page_token) except Exception as e: raise HTTPException(status_code=500, detail=f"Server error: {str(e)}") @api_router.delete("/files/{file_id}", summary="删除文件(到回收站)", tags=["文件"]) async def delete_file_info(file_id: str = Path(...)): logger.info(f"API Request: Delete file/folder ID: {file_id}") try: return await THUNDERX_CLIENT.delete_to_trash([file_id]) except Exception as e: raise HTTPException(status_code=500, detail=f"Server error: {str(e)}") @api_router.post("/file_rename/{file_id}", summary="重命名文件", tags=["文件"]) async def file_rename(file_id: str = Path(...), name: str = Body(..., embed=True)): new_name = name.strip(); if not new_name: raise HTTPException(status_code=400, detail="Name cannot be empty") logger.info(f"API Request: Rename ID: {file_id} to '{new_name}'") try: return await THUNDERX_CLIENT.file_rename(file_id, new_name) except Exception as e: raise HTTPException(status_code=500, detail=f"Server error: {str(e)}") @api_router.post("/file_batch_copy", summary="批量复制文件", tags=["文件"]) async def file_batch_copy(ids: List[str] = Body(..., embed=True), to_parent_id: str = Body(..., embed=True)): if not ids: raise HTTPException(status_code=400, detail="IDs list required") logger.info(f"API Request: Batch copy IDs: {ids} to Parent: {to_parent_id}") try: return await THUNDERX_CLIENT.file_batch_copy(ids, to_parent_id) except Exception as e: raise HTTPException(status_code=500, detail=f"Server error: {str(e)}") @api_router.post("/file_batch_move", summary="批量移动文件", tags=["文件"]) async def file_batch_move(ids: List[str] = Body(..., embed=True), to_parent_id: str = Body(..., embed=True)): if not ids: raise HTTPException(status_code=400, detail="IDs list required") logger.info(f"API Request: Batch move IDs: {ids} to Parent: {to_parent_id}") try: return await THUNDERX_CLIENT.file_batch_move(ids, to_parent_id) except Exception as e: raise HTTPException(status_code=500, detail=f"Server error: {str(e)}") @api_router.post("/create_folder", summary="创建文件夹", tags=["文件"]) async def create_folder(name: str = Body(..., embed=True), parent_id: str = Body("", embed=True)): folder_name = name.strip(); if not folder_name: raise HTTPException(status_code=400, detail="Name required") logger.info(f"API Request: Create folder '{folder_name}' in Parent: {parent_id or 'root'}") try: return await THUNDERX_CLIENT.create_folder(folder_name, parent_id) except Exception as e: raise HTTPException(status_code=500, detail=f"Server error: {str(e)}") @api_router.post("/delete_to_trash", summary="批量移到回收站", tags=["文件"]) async def delete_to_trash_api(ids: List[str] = Body(..., embed=True)): if not ids: raise HTTPException(status_code=400, detail="IDs list required") logger.info(f"API Request: Move to trash IDs: {ids}") try: return await THUNDERX_CLIENT.delete_to_trash(ids) except Exception as e: raise HTTPException(status_code=500, detail=f"Server error: {str(e)}") @api_router.post("/delete_forever", summary="批量彻底删除", tags=["文件"]) async def delete_forever_api(ids: List[str] = Body(..., embed=True)): if not ids: raise HTTPException(status_code=400, detail="IDs list required") logger.warning(f"API Request: PERMANENTLY DELETE IDs: {ids}") try: return await THUNDERX_CLIENT.delete_forever(ids) except Exception as e: raise HTTPException(status_code=500, detail=f"Server error: {str(e)}") @api_router.post("/untrash", summary="批量从回收站恢复", tags=["文件"]) async def untrash_api(ids: List[str] = Body(..., embed=True)): if not ids: raise HTTPException(status_code=400, detail="IDs list required") logger.info(f"API Request: Restore from trash IDs: {ids}") try: return await THUNDERX_CLIENT.untrash(ids) except Exception as e: raise HTTPException(status_code=500, detail=f"Server error: {str(e)}") @api_router.post("/file_batch_star", summary="批量加星", tags=["文件"]) async def file_batch_star(ids: List[str] = Body(..., embed=True)): if not ids: raise HTTPException(status_code=400, detail="IDs list required") logger.info(f"API Request: Star items IDs: {ids}") try: return await THUNDERX_CLIENT.file_batch_star(ids) except Exception as e: raise HTTPException(status_code=500, detail=f"Server error: {str(e)}") @api_router.post("/file_batch_unstar", summary="批量取消加星", tags=["文件"]) async def file_batch_unstar(ids: List[str] = Body(..., embed=True)): if not ids: raise HTTPException(status_code=400, detail="IDs list required") logger.info(f"API Request: Unstar items IDs: {ids}") try: return await THUNDERX_CLIENT.file_batch_unstar(ids) except Exception as e: raise HTTPException(status_code=500, detail=f"Server error: {str(e)}") @api_router.post("/emptytrash", summary="清空回收站", tags=["文件"]) async def emptytrash_api(): logger.warning(f"API Request: Empty trash.") try: return await THUNDERX_CLIENT.emptytrash() except Exception as e: raise HTTPException(status_code=500, detail=f"Server error: {str(e)}") @api_router.get("/get_share_list", summary="获取分享列表", tags=["分享"]) async def get_share_list(next_page_token: str = Query("")): logger.info(f"API Request: Get share list. Token: {next_page_token or 'start'}") try: return await THUNDERX_CLIENT.get_share_list(next_page_token) except Exception as e: raise HTTPException(status_code=500, detail=f"Server error: {str(e)}") @api_router.post("/file_batch_share", summary="批量创建分享", tags=["分享"]) async def file_batch_share(file_ids: List[str]=Body(..., embed=True), need_password: bool=Body(False, embed=True), expiration_days: int=Body(-1, embed=True)): if not file_ids: raise HTTPException(status_code=400, detail="File IDs required") logger.info(f"API Request: Create share IDs: {file_ids}, Pwd: {need_password}, Exp: {expiration_days}") try: return await THUNDERX_CLIENT.file_batch_share(file_ids, need_password, expiration_days) except Exception as e: raise HTTPException(status_code=500, detail=f"Server error: {str(e)}") @api_router.post("/share_batch_delete", summary="批量取消分享", tags=["分享"]) async def share_batch_delete(share_ids: List[str] = Body(..., embed=True)): if not share_ids: raise HTTPException(status_code=400, detail="Share IDs required") logger.info(f"API Request: Delete shares IDs: {share_ids}") try: return await THUNDERX_CLIENT.share_batch_delete(share_ids) except Exception as e: raise HTTPException(status_code=500, detail=f"Server error: {str(e)}") @api_router.post("/get_share_folder/{share_id}", summary="获取分享文件夹信息", tags=["分享"]) async def get_share_folder(share_id: str = Path(...), parent_id: str = Body("", embed=True)): logger.info(f"API Request: Get share folder. Share: {share_id}, Folder: {parent_id or 'root'}") try: return await THUNDERX_CLIENT.get_share_folder(share_id, parent_id) except Exception as e: raise HTTPException(status_code=500, detail=f"Server error: {str(e)}") @api_router.post("/restore/{share_id}", summary="转存分享", tags=["分享"]) async def restore_api(share_id: str=Path(...), file_ids: Optional[List[str]]=Body(None, embed=True), parent_folder_id: Optional[str]=Body(None, embed=True)): target_folder = parent_folder_id if parent_folder_id is not None else DEFAULT_DOWNLOAD_FOLDER_ID files_to_restore = file_ids if file_ids else None logger.info(f"API Request: Restore share. Share: {share_id}, Files: {files_to_restore or 'All'}, Target: {target_folder or 'Root'}") try: result = await THUNDERX_CLIENT.restore(share_id, files_to_restore, target_folder) if isinstance(result, dict) and result.get("error"): raise HTTPException(status_code=400, detail=result.get("error_description", "API error")) return result except HTTPException: raise except Exception as e: if "System folder type is unknown" in str(e): raise HTTPException(status_code=403, detail="Restore failed: System folder type is unknown") raise HTTPException(status_code=500, detail=f"Server error: {str(e)}") @api_router.get("/offline_list", summary="离线任务列表", tags=["离线任务"]) async def offline_list(size: int=Query(100), next_page_token: Optional[str]=Query(None), phase: Optional[str]=Query(None)): logger.info(f"API Request: Get offline list. Size: {size}, Token: {next_page_token}, Phase: {phase}") try: return await THUNDERX_CLIENT.offline_list(size, next_page_token, phase) except Exception as e: raise HTTPException(status_code=500, detail=f"Server error: {str(e)}") @api_router.post("/delete_tasks", summary="批量删除离线任务", tags=["离线任务"]) async def delete_tasks_api(task_ids: List[str]=Body(..., embed=True), delete_files: bool=Body(False, embed=True)): if not task_ids: raise HTTPException(status_code=400, detail="Task IDs required") log_level = logging.WARNING if delete_files else logging.INFO logger.log(log_level, f"API Request: Delete tasks IDs: {task_ids}, Delete files: {delete_files}") try: return await THUNDERX_CLIENT.delete_tasks(task_ids, delete_files) except Exception as e: raise HTTPException(status_code=500, detail=f"Server error: {str(e)}") @api_router.get("/userinfo", summary="用户信息", tags=["账号"]) async def userinfo(): logger.info("API Request: Get user info.") try: return await THUNDERX_CLIENT.get_user_info() except Exception as e: raise HTTPException(status_code=500, detail=f"Server error: {str(e)}") @api_router.get("/quota", summary="空间使用信息", tags=["账号"]) async def quota_info_api(): logger.info("API Request: Get quota info.") try: return await THUNDERX_CLIENT.get_quota_info() except Exception as e: raise HTTPException(status_code=500, detail=f"Server error: {str(e)}") @api_router.post("/get_invite_code", summary="获取邀请码信息", tags=["账号"]) async def get_invite_code(channel: str = Body("TELEGRAM", embed=True)): logger.info(f"API Request: Get invite code channel: {channel}") try: return await THUNDERX_CLIENT.get_invite_code(channel) except Exception as e: raise HTTPException(status_code=500, detail=f"Server error: {str(e)}") # --- Include routers --- app.include_router(front_router) app.include_router(api_router) # --- END OF FILE main.py ---